Merge branch 'dev' into main for hotfix
All checks were successful
ERP-Deploy-Production / deploy-production (push) Successful in 1m11s
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s

This commit is contained in:
2026-03-10 15:33:27 +08:00
73 changed files with 2400 additions and 445 deletions

View File

@@ -62,6 +62,10 @@ trigger: always_on
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。 * 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。 * 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
* 新增功能時,請先判斷應歸屬於哪個 Module並建立在 `app/Modules/` 對應目錄下。 * 新增功能時,請先判斷應歸屬於哪個 Module並建立在 `app/Modules/` 對應目錄下。
* **核心要求UI 規範與彈性設計 (重要)**
* 在開發「新功能」或「新頁面」前,產出的 `implementation_plan.md` 中**必須包含「UI 規範核對清單」**,明確列出將使用哪些已定義於 `ui-consistency/SKILL.md` 的元件(例如:`AlertDialog`、特定圖示名稱等)。
* **已規範部分**:絕對遵循《客戶端後台 UI 統一規範》進行實作。
* **未規範部分**:若遇到規範外的新 UI 區塊,請保有設計彈性,運用 Tailwind 打造符合 ERP 調性的初版設計,依據使用者的實際感受進行後續調整與收錄。
## 8. 多租戶開發規範 (Multi-tenancy Standards) ## 8. 多租戶開發規範 (Multi-tenancy Standards)
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則: 本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
@@ -84,3 +88,13 @@ trigger: always_on
* **自動化部署**:本專案使用 CI/CD 自動化部署,開發者只需 push 程式碼至對應分支即可。 * **自動化部署**:本專案使用 CI/CD 自動化部署,開發者只需 push 程式碼至對應分支即可。
* **Demo 環境 (對應 `demo` 分支)**:若需查修測試站問題(例如查看 Error Log 或資料庫),請連線 `ssh gitea_work` * **Demo 環境 (對應 `demo` 分支)**:若需查修測試站問題(例如查看 Error Log 或資料庫),請連線 `ssh gitea_work`
* **Production 環境 (對應 `main` 分支)**:若需查修正式站問題,請連線 `ssh erp` * **Production 環境 (對應 `main` 分支)**:若需查修正式站問題,請連線 `ssh erp`
## 11. 瀏覽器測試規範 (Browser Testing)
當需要進行瀏覽器自動化測試或手動驗證時,請遵守以下連線資訊:
* **本地測試網址**`http://localhost:8081/`
* **預設管理員帳號**`admin`
* **預設管理員密碼**`password`
> [!IMPORTANT]
> 在執行 browser subagent 或進行 E2E 測試時,請務必確認為 `8081` Port以避免連線至錯誤的服務環境。

View File

@@ -19,6 +19,8 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
| 跨模組、Service Interface、`Contracts`、模組間通訊、`ServiceProvider` 綁定、禁止跨模組引用 | **跨模組調用與通訊規範** | `.agents/skills/cross-module-communication/SKILL.md` | | 跨模組、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` | | 按鈕樣式、表格規範、圖標、分頁、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` | | 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** — 設定權限 1. **permission-management** — 設定權限
2. **ui-consistency** — 遵循 UI 規範 2. **ui-consistency** — 遵循 UI 規範
3. **activity-logging** — 若涉及 Model CRUD需加上操作紀錄 3. **activity-logging** — 若涉及 Model CRUD需加上操作紀錄
4. **e2e-testing** — 確認是否需要新增對應的 E2E 測試
### 🔴 新增或修改 Model 時 ### 🔴 新增或修改 Model 時
必須讀取: 必須讀取:
@@ -41,6 +44,10 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
必須讀取: 必須讀取:
1. **git-workflows** — 分支命名與 commit 格式 1. **git-workflows** — 分支命名與 commit 格式
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
必須讀取:
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
--- ---
## 注意事項 ## 注意事項

View 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`

View File

@@ -18,11 +18,12 @@ description: 規範開發過程中的 Git 分支架構、合併限制、環境
## 2. 發布時段與約束 (Release Window) ## 2. 發布時段與約束 (Release Window)
### Main 分支發布限制 (Mandatory) ### Main 分支發布限制 (Mandatory)
1. **標準發布時間**:週一至週四,**12:00 (中午) 之前** 1. **強制規範**:若執行推送/合併指令時未明確包含目標分支,**嚴禁** 自行預設或推論為 `main`。我必須先詢問使用者:「請問要推送到哪一個目標分支?(dev / demo / main)」
2. **標準時段提醒**若於上述時段以外(週五、週末、國定假日或下班時間)欲合併至 `main` 2. **標準發布時間**週一至週四,**12:00 (中午) 之前**。
3. **非標準時段提醒**:若於上述時段以外(週五、週末、國定假日或下班時間)欲合併至 `main`
- AI 助手**必須攔截並主動提示風險**(例如:週末災難風險)。 - AI 助手**必須攔截並主動提示風險**(例如:週末災難風險)。
- 必須取得使用者明確書面同意(如:「我確定現在要上線」)方可執行。 - 必須取得使用者明確書面同意(如:「我確定現在要上線」)方可執行。
3. **合併鏈路**:一般功能/修正必須先上 `demo` 測試。`main` 的程式原則上應從 `demo` 分支合併而來。 4. **合併鏈路**:一般功能/修正必須先上 `demo` 測試。`main` 的程式原則上應從 `demo` 分支合併而來。
## 3. 開發與修復流程 (SOP) ## 3. 開發與修復流程 (SOP)

View File

@@ -781,7 +781,38 @@ import { SearchableSelect } from "@/Components/ui/searchable-select";
- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9` - **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9`
- **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。 - **禁止使用**: 除非有特殊設計需求,否則避免使用 `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` 提供的工具函式。 前端顯示日期時**禁止直接顯示原始 ISO 字串**(如 `2024-03-06T08:30:00.000000Z`),必須使用 `resources/js/lib/date.ts` 提供的工具函式。

View File

@@ -8,28 +8,20 @@ description: 將目前的變更提交並推送至指定的遠端分支 (遵守
## 執行步驟 ## 執行步驟
1. **檢查變更內容** 1. **讀取規範 (Mandatory)**
執行 `git status``git diff` 檢查目前的工作目錄,確保提交內容正確。 執行任何 Git 操作前,**必須** 先讀取 Git 分支管理與開發規範:
`view_file` -> [Git SKILL.md](file:///home/mama/projects/star-erp/.agents/skills/git-workflows/SKILL.md)
2. **撰寫規格化提交訊息 (Commit Message)** 2. **檢查與準備**
- 訊息一律使用 **繁體中文 (台灣用語)** - 執行 `git status` 檢查目前工作目錄
- 必須使用以下前綴之一: - 根據 **SKILL.md** 的規範撰寫繁體中文提交訊息。
- `[FIX]`:修復 Bug。
- `[FEAT]`:新增功能。
- `[DOCS]`:文件更新。
- `[STYLE]`UI/樣式/格式調整。
- `[REFACTOR]`:程式碼重構。
- 描述應具體且真實反映修改內容。
3. **目標分支安全檢查 (Release Window & Source Check)** 3. **目標分支安全檢查**
- 若使用者指定的目標分支包含 **`main`** - 嚴格遵守 **SKILL.md** 中的「分支架構」、「發布時段」與「強制分支明確指定」規則。
- **來源檢查**:根據規範,上線 `main` 前必須先確保程式碼已在 `demo` 分支驗證完畢。我會優先檢查 `demo``main` 的差異,並提醒使用者應從 `demo` 合併 - 若未指明目標分支,主動詢問使用者,不可私自預設為 `main`
- **檢查目前時間**:標準發布時段為 **週一至週四 12:00 (中午) 之前**
- 若在非標準時段(週五、週末、下班時間),**必須** 先攔截並主動提醒風險,取得使用者明確書面同意(例如:「我確定現在要上線」)後方才執行推送。
4. **執行推送 (Push)** 4. **執行推送 (Push)**
- 依據指令帶入的分支名稱執行推送 - 通過安全檢查後,執行:`git push origin [目前分支]:[目標分支]`
- 範例:`git push origin [目前分支]:[目標分支]`
5. **同步關聯分支** 5. **後續同步**
- 若為 `main`Hotfix,修復後應評估是否同步回 `demo``dev` 分支。 - 依照 **SKILL.md** 的「緊急修復流程(Hotfix)」評估是否需要同步回 `demo``dev` 分支。

10
.gitignore vendored
View File

@@ -18,6 +18,7 @@
/public/storage /public/storage
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/storage/tenant*
/vendor /vendor
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
@@ -33,3 +34,12 @@ docs/f6_1770350984272.xlsx
.gitignore .gitignore
BOM表自動計算成本.md BOM表自動計算成本.md
公共事業費-類別維護.md 公共事業費-類別維護.md
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
e2e/screenshots/

View File

@@ -180,4 +180,3 @@ docker compose down
- **多租戶**: - **多租戶**:
- 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。 - 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。
- 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。 - 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。

View File

@@ -151,7 +151,7 @@ class RoleController extends Controller
*/ */
private function getGroupedPermissions() private function getGroupedPermissions()
{ {
$allPermissions = Permission::orderBy('name')->get(); $allPermissions = Permission::select('id', 'name', 'guard_name')->orderBy('name')->get();
$grouped = []; $grouped = [];
foreach ($allPermissions as $permission) { foreach ($allPermissions as $permission) {

View File

@@ -16,7 +16,7 @@ class CoreService implements CoreServiceInterface
*/ */
public function getUsersByIds(array $ids): Collection 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 public function getAllUsers(): Collection
{ {
return User::all(); return User::select('id', 'name')->get();
} }
public function ensureSystemUserExists() public function ensureSystemUserExists()

View File

@@ -69,14 +69,25 @@ class AccountingReportController extends Controller
} }
$exportData = $allRecords->map(function ($record) { $exportData = $allRecords->map(function ($record) {
$taxAmount = (float)($record['tax_amount'] ?? 0);
$totalAmount = (float)($record['amount'] ?? 0);
$untaxedAmount = $totalAmount - $taxAmount;
return [ return [
$record['date'], $record['date'],
$record['source'], $record['source'],
$record['category'], $record['category'],
$record['item'], $record['item'],
$record['reference'], $record['reference'],
$record['invoice_number'], $record['invoice_date'] ?? '-',
$record['amount'], $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 // BOM for Excel compatibility with UTF-8
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF)); fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']); fputcsv($file, [
'日期', '來源', '類別', '項目', '參考單號',
'發票日期', '發票號碼', '未稅金額', '稅額', '總金額',
'付款方式', '付款備註', '內部備註', '狀態'
]);
foreach ($exportData as $row) { foreach ($exportData as $row) {
fputcsv($file, $row); fputcsv($file, $row);

View File

@@ -4,8 +4,10 @@ namespace App\Modules\Finance\Controllers;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Modules\Finance\Models\UtilityFee; use App\Modules\Finance\Models\UtilityFee;
use App\Modules\Finance\Models\UtilityFeeAttachment;
use App\Modules\Finance\Contracts\FinanceServiceInterface; use App\Modules\Finance\Contracts\FinanceServiceInterface;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia; use Inertia\Inertia;
class UtilityFeeController extends Controller class UtilityFeeController extends Controller
@@ -103,8 +105,82 @@ class UtilityFeeController extends Controller
->event('deleted') ->event('deleted')
->log('deleted'); ->log('deleted');
// 刪除實體檔案 (如果 cascade 沒處理或是想要手動清理)
foreach ($utility_fee->attachments as $attachment) {
Storage::disk('public')->delete($attachment->file_path);
}
$utility_fee->delete(); $utility_fee->delete();
return redirect()->back(); 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' => '刪除成功']);
}
} }

View File

@@ -7,9 +7,16 @@ use Illuminate\Database\Eloquent\Model;
class UtilityFee extends Model class UtilityFee extends Model
{ {
/** @use HasFactory<\Database\Factories\UtilityFeeFactory> */
use HasFactory; use HasFactory;
/**
* 此公共事業費的附件
*/
public function attachments()
{
return $this->hasMany(UtilityFeeAttachment::class);
}
// 狀態常數 // 狀態常數
const STATUS_PENDING = 'pending'; const STATUS_PENDING = 'pending';
const STATUS_PAID = 'paid'; const STATUS_PAID = 'paid';

View 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);
}
}

View File

@@ -30,6 +30,11 @@ Route::middleware('auth')->group(function () {
}); });
Route::middleware('permission:utility_fees.edit')->group(function () { Route::middleware('permission:utility_fees.edit')->group(function () {
Route::put('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'update'])->name('utility-fees.update'); 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::middleware('permission:utility_fees.delete')->group(function () {
Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy'); Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy');

View File

@@ -70,6 +70,7 @@ class AccountPayableService
$latest = AccountPayable::where('document_number', 'like', $lastPrefix) $latest = AccountPayable::where('document_number', 'like', $lastPrefix)
->orderBy('document_number', 'desc') ->orderBy('document_number', 'desc')
->lockForUpdate()
->first(); ->first();
if (!$latest) { if (!$latest) {

View File

@@ -19,23 +19,48 @@ class FinanceService implements FinanceServiceInterface
public function getAccountingReportData(string $start, string $end): array public function getAccountingReportData(string $start, string $end): array
{ {
// 1. 獲取採購單資料 // 1. 獲取應付帳款資料 (已付款)
$purchaseOrders = $this->procurementService->getPurchaseOrdersByDate($start, $end) $accountPayables = \App\Modules\Finance\Models\AccountPayable::where('status', \App\Modules\Finance\Models\AccountPayable::STATUS_PAID)
->map(function ($po) { ->whereNotNull('paid_at')
return [ ->whereBetween('paid_at', [$start, $end])
'id' => 'PO-' . $po->id, ->get();
'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,
];
});
// 2. 獲取公共事業費 (注意:目前資料表欄位為 transaction_date) // 取得供應商資料 (Manual Hydration)
$utilityFees = UtilityFee::whereBetween('transaction_date', [$start, $end]) $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() ->get()
->map(function ($fee) { ->map(function ($fee) {
return [ return [
@@ -45,12 +70,18 @@ class FinanceService implements FinanceServiceInterface
'category' => $fee->category, 'category' => $fee->category,
'item' => $fee->description ?: $fee->category, 'item' => $fee->description ?: $fee->category,
'reference' => '-', 'reference' => '-',
'invoice_date' => null,
'invoice_number' => $fee->invoice_number, 'invoice_number' => $fee->invoice_number,
'amount' => (float)$fee->amount, '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') ->sortByDesc('date')
->values(); ->values();
@@ -58,7 +89,7 @@ class FinanceService implements FinanceServiceInterface
'records' => $allRecords, 'records' => $allRecords,
'summary' => [ 'summary' => [
'total_amount' => $allRecords->sum('amount'), 'total_amount' => $allRecords->sum('amount'),
'purchase_total' => $purchaseOrders->sum('amount'), 'payable_total' => $payableRecords->sum('amount'),
'utility_total' => $utilityFees->sum('amount'), 'utility_total' => $utilityFees->sum('amount'),
'record_count' => $allRecords->count(), 'record_count' => $allRecords->count(),
] ]
@@ -67,7 +98,7 @@ class FinanceService implements FinanceServiceInterface
public function getUtilityFees(array $filters) public function getUtilityFees(array $filters)
{ {
$query = UtilityFee::query(); $query = UtilityFee::withCount('attachments');
if (!empty($filters['search'])) { if (!empty($filters['search'])) {
$search = $filters['search']; $search = $filters['search'];

View File

@@ -133,7 +133,10 @@ class SyncOrderAction
$warehouseId, $warehouseId,
$qty, $qty,
"POS Order: " . $order->external_order_id, "POS Order: " . $order->external_order_id,
true true,
null,
\App\Modules\Integration\Models\SalesOrder::class,
$order->id
); );
} }

View File

@@ -130,7 +130,10 @@ class SyncVendingOrderAction
$warehouseId, $warehouseId,
$qty, $qty,
"Vending Order: " . $order->external_order_id, "Vending Order: " . $order->external_order_id,
true true,
null,
\App\Modules\Integration\Models\SalesOrder::class,
$order->id
); );
} }

View File

@@ -23,7 +23,7 @@ interface InventoryServiceInterface
* @param string|null $slot * @param string|null $slot
* @return void * @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): void;
/** /**
* Get all active warehouses. * Get all active warehouses.

View File

@@ -63,7 +63,7 @@ class AdjustDocController extends Controller
return Inertia::render('Inventory/Adjust/Index', [ return Inertia::render('Inventory/Adjust/Index', [
'docs' => $docs, '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']), 'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
]); ]);
} }

View File

@@ -67,7 +67,7 @@ class CountDocController extends Controller
return Inertia::render('Inventory/Count/Index', [ return Inertia::render('Inventory/Count/Index', [
'docs' => $docs, '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']), 'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
]); ]);
} }

View File

@@ -41,7 +41,7 @@ class InventoryController extends Controller
'inventories.lastIncomingTransaction', 'inventories.lastIncomingTransaction',
'inventories.lastOutgoingTransaction' 'inventories.lastOutgoingTransaction'
]); ]);
$allProducts = Product::with('category')->get(); $allProducts = Product::select('id', 'name', 'code', 'category_id')->with('category:id,name')->get();
// 1. 準備 availableProducts // 1. 準備 availableProducts
$availableProducts = $allProducts->map(function ($product) { $availableProducts = $allProducts->map(function ($product) {
@@ -167,8 +167,8 @@ class InventoryController extends Controller
public function create(Warehouse $warehouse) public function create(Warehouse $warehouse)
{ {
// ... (unchanged) ... // ... (unchanged) ...
$products = Product::with(['baseUnit', 'largeUnit']) $products = Product::select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
->select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price') ->with(['baseUnit:id,name', 'largeUnit:id,name'])
->get() ->get()
->map(function ($product) { ->map(function ($product) {
return [ return [

View File

@@ -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', [ return Inertia::render('Product/Index', [
'products' => $products, 'products' => $products,
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]), 'categories' => $categories->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]), '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']), 'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
]); ]);
} }
@@ -172,8 +172,8 @@ class ProductController extends Controller
public function create(): Response public function create(): Response
{ {
return Inertia::render('Product/Create', [ return Inertia::render('Product/Create', [
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]), 'categories' => Category::select('id', 'name')->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]), '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, 'wholesale_price' => (float) $product->wholesale_price,
'is_active' => (bool) $product->is_active, 'is_active' => (bool) $product->is_active,
], ],
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]), 'categories' => Category::select('id', 'name')->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]), 'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
]); ]);
} }

View File

@@ -19,7 +19,7 @@ class SafetyStockController extends Controller
*/ */
public function index(Warehouse $warehouse) 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) { $availableProducts = $allProducts->map(function ($product) {

View File

@@ -65,7 +65,7 @@ class TransferOrderController extends Controller
return Inertia::render('Inventory/Transfer/Index', [ return Inertia::render('Inventory/Transfer/Index', [
'orders' => $orders, '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']), 'filters' => $request->only(['search', 'warehouse_id', 'per_page']),
]); ]);
} }

View File

@@ -44,16 +44,23 @@ class AdjustService
); );
// 2. 抓取有差異的明細 (diff_qty != 0) // 2. 抓取有差異的明細 (diff_qty != 0)
$itemsToInsert = [];
foreach ($countDoc->items as $item) { foreach ($countDoc->items as $item) {
if (abs($item->diff_qty) < 0.0001) continue; if (abs($item->diff_qty) < 0.0001) continue;
$adjDoc->items()->create([ $itemsToInsert[] = [
'inventory_adjust_doc_id' => $adjDoc->id,
'product_id' => $item->product_id, 'product_id' => $item->product_id,
'batch_number' => $item->batch_number, 'batch_number' => $item->batch_number,
'qty_before' => $item->system_qty, 'qty_before' => $item->system_qty,
'adjust_qty' => $item->diff_qty, 'adjust_qty' => $item->diff_qty,
'notes' => "盤點差異: " . $item->diff_qty, 'notes' => "盤點差異: " . $item->diff_qty,
]); 'created_at' => now(),
'updated_at' => now(),
];
}
if (!empty($itemsToInsert)) {
InventoryAdjustItem::insert($itemsToInsert);
} }
return $adjDoc; return $adjDoc;
@@ -84,25 +91,35 @@ class AdjustService
$doc->items()->delete(); $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) { foreach ($itemsData as $data) {
// 取得當前庫存作為 qty_before 參考 (僅參考,實際扣減以過帳當下為準) $inventory = $inventories->where('product_id', $data['product_id'])
$inventory = Inventory::where('warehouse_id', $doc->warehouse_id)
->where('product_id', $data['product_id'])
->where('batch_number', $data['batch_number'] ?? null) ->where('batch_number', $data['batch_number'] ?? null)
->first(); ->first();
$qtyBefore = $inventory ? $inventory->quantity : 0; $qtyBefore = $inventory ? $inventory->quantity : 0;
$newItem = $doc->items()->create([ $itemsToInsert[] = [
'inventory_adjust_doc_id' => $doc->id,
'product_id' => $data['product_id'], 'product_id' => $data['product_id'],
'batch_number' => $data['batch_number'] ?? null, 'batch_number' => $data['batch_number'] ?? null,
'qty_before' => $qtyBefore, 'qty_before' => $qtyBefore,
'adjust_qty' => $data['adjust_qty'], 'adjust_qty' => $data['adjust_qty'],
'notes' => $data['notes'] ?? null, '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; $found = false;
foreach ($updatedItems as $idx => $ui) { foreach ($updatedItems as $idx => $ui) {
if ($ui['product_name'] === $productName && $ui['new'] === null) { if ($ui['product_name'] === $productName && $ui['new'] === null) {
@@ -126,6 +143,10 @@ class AdjustService
} }
} }
if (!empty($itemsToInsert)) {
InventoryAdjustItem::insert($itemsToInsert);
}
// 清理沒被更新到的舊品項 (即真正被刪除的) // 清理沒被更新到的舊品項 (即真正被刪除的)
$finalUpdatedItems = []; $finalUpdatedItems = [];
foreach ($updatedItems as $ui) { foreach ($updatedItems as $ui) {
@@ -162,11 +183,20 @@ class AdjustService
foreach ($doc->items as $item) { foreach ($doc->items as $item) {
if ($item->adjust_qty == 0) continue; if ($item->adjust_qty == 0) continue;
$inventory = Inventory::firstOrNew([ // 補上 lockForUpdate() 防止併發衝突
$inventory = Inventory::where([
'warehouse_id' => $doc->warehouse_id, 'warehouse_id' => $doc->warehouse_id,
'product_id' => $item->product_id, 'product_id' => $item->product_id,
'batch_number' => $item->batch_number, '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 並先行儲存 // 如果是新建立的 object (id 為空),需要初始化 default 並先行儲存
if (!$inventory->exists) { if (!$inventory->exists) {

View File

@@ -47,14 +47,15 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
$productIds = collect($data['items'])->pluck('product_id')->unique()->toArray(); $productIds = collect($data['items'])->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$itemsToInsert = [];
foreach ($data['items'] as $itemData) { foreach ($data['items'] as $itemData) {
// 非標準類型:使用手動輸入的小計;標準類型:自動計算 // 非標準類型:使用手動輸入的小計;標準類型:自動計算
$totalAmount = !empty($itemData['subtotal']) && $data['type'] !== 'standard' $totalAmount = !empty($itemData['subtotal']) && $data['type'] !== 'standard'
? (float) $itemData['subtotal'] ? (float) $itemData['subtotal']
: $itemData['quantity_received'] * $itemData['unit_price']; : $itemData['quantity_received'] * $itemData['unit_price'];
// Create GR Item $itemsToInsert[] = [
$grItem = new GoodsReceiptItem([ 'goods_receipt_id' => $goodsReceipt->id,
'product_id' => $itemData['product_id'], 'product_id' => $itemData['product_id'],
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null, 'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
'quantity_received' => $itemData['quantity_received'], 'quantity_received' => $itemData['quantity_received'],
@@ -62,8 +63,9 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
'total_amount' => $totalAmount, 'total_amount' => $totalAmount,
'batch_number' => $itemData['batch_number'] ?? null, 'batch_number' => $itemData['batch_number'] ?? null,
'expiry_date' => $itemData['expiry_date'] ?? null, 'expiry_date' => $itemData['expiry_date'] ?? null,
]); 'created_at' => now(),
$goodsReceipt->items()->save($grItem); 'updated_at' => now(),
];
$product = $products->get($itemData['product_id']); $product = $products->get($itemData['product_id']);
$diff['added'][] = [ $diff['added'][] = [
@@ -76,6 +78,10 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
]; ];
} }
if (!empty($itemsToInsert)) {
GoodsReceiptItem::insert($itemsToInsert);
}
// 4. 手動發送高品質日誌(包含品項明細) // 4. 手動發送高品質日誌(包含品項明細)
activity() activity()
->performedOn($goodsReceipt) ->performedOn($goodsReceipt)
@@ -146,13 +152,15 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
if (isset($data['items'])) { if (isset($data['items'])) {
$goodsReceipt->items()->delete(); $goodsReceipt->items()->delete();
$itemsToInsert = [];
foreach ($data['items'] as $itemData) { foreach ($data['items'] as $itemData) {
// 非標準類型:使用手動輸入的小計;標準類型:自動計算 // 非標準類型:使用手動輸入的小計;標準類型:自動計算
$totalAmount = !empty($itemData['subtotal']) && $goodsReceipt->type !== 'standard' $totalAmount = !empty($itemData['subtotal']) && $goodsReceipt->type !== 'standard'
? (float) $itemData['subtotal'] ? (float) $itemData['subtotal']
: $itemData['quantity_received'] * $itemData['unit_price']; : $itemData['quantity_received'] * $itemData['unit_price'];
$grItem = new GoodsReceiptItem([ $itemsToInsert[] = [
'goods_receipt_id' => $goodsReceipt->id,
'product_id' => $itemData['product_id'], 'product_id' => $itemData['product_id'],
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null, 'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
'quantity_received' => $itemData['quantity_received'], 'quantity_received' => $itemData['quantity_received'],
@@ -160,8 +168,13 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
'total_amount' => $totalAmount, 'total_amount' => $totalAmount,
'batch_number' => $itemData['batch_number'] ?? null, 'batch_number' => $itemData['batch_number'] ?? null,
'expiry_date' => $itemData['expiry_date'] ?? null, 'expiry_date' => $itemData['expiry_date'] ?? null,
]); 'created_at' => now(),
$goodsReceipt->items()->save($grItem); '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) 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) { 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->status = GoodsReceipt::STATUS_COMPLETED;
$goodsReceipt->save(); $goodsReceipt->save();

View File

@@ -13,7 +13,7 @@ class InventoryService implements InventoryServiceInterface
{ {
public function getAllWarehouses() public function getAllWarehouses()
{ {
return Warehouse::all(); return Warehouse::select('id', 'name', 'code', 'type')->get();
} }
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection
@@ -38,12 +38,14 @@ class InventoryService implements InventoryServiceInterface
public function getAllProducts() 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() 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 = []) public function getInventoriesByIds(array $ids, array $with = [])
@@ -85,9 +87,9 @@ class InventoryService implements InventoryServiceInterface
return $stock >= $quantity; 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): void
{ {
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot) { DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot, $referenceType, $referenceId) {
$query = Inventory::where('product_id', $productId) $query = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId) ->where('warehouse_id', $warehouseId)
->where('quantity', '>', 0); ->where('quantity', '>', 0);
@@ -96,7 +98,8 @@ class InventoryService implements InventoryServiceInterface
$query->where('location', $slot); $query->where('location', $slot);
} }
$inventories = $query->orderBy('arrival_date', 'asc') $inventories = $query->lockForUpdate()
->orderBy('arrival_date', 'asc')
->get(); ->get();
$remainingToDecrease = $quantity; $remainingToDecrease = $quantity;
@@ -105,7 +108,7 @@ class InventoryService implements InventoryServiceInterface
if ($remainingToDecrease <= 0) break; if ($remainingToDecrease <= 0) break;
$decreaseAmount = min($inventory->quantity, $remainingToDecrease); $decreaseAmount = min($inventory->quantity, $remainingToDecrease);
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason); $this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason, $referenceType, $referenceId);
$remainingToDecrease -= $decreaseAmount; $remainingToDecrease -= $decreaseAmount;
} }
@@ -136,7 +139,7 @@ class InventoryService implements InventoryServiceInterface
]); ]);
} }
$this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason); $this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason, $referenceType, $referenceId);
} else { } else {
throw new \Exception("庫存不足,無法扣除所有請求的數量。"); throw new \Exception("庫存不足,無法扣除所有請求的數量。");
} }

View File

@@ -53,15 +53,22 @@ class StoreRequisitionService
// 靜默建立以抑制自動日誌 // 靜默建立以抑制自動日誌
$requisition->saveQuietly(); $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' => []]; $diff = ['added' => [], 'removed' => [], 'updated' => []];
foreach ($items as $item) { foreach ($items as $item) {
$requisition->items()->create([ $itemsToInsert[] = [
'store_requisition_id' => $requisition->id,
'product_id' => $item['product_id'], 'product_id' => $item['product_id'],
'requested_qty' => $item['requested_qty'], 'requested_qty' => $item['requested_qty'],
'remark' => $item['remark'] ?? null, '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'][] = [ $diff['added'][] = [
'product_name' => $product?->name ?? '未知商品', 'product_name' => $product?->name ?? '未知商品',
'new' => [ 'new' => [
@@ -70,6 +77,7 @@ class StoreRequisitionService
] ]
]; ];
} }
StoreRequisitionItem::insert($itemsToInsert);
// 如果需直接提交,觸發通知 // 如果需直接提交,觸發通知
if ($submitImmediately) { if ($submitImmediately) {
@@ -179,13 +187,18 @@ class StoreRequisitionService
// 儲存實際變動 // 儲存實際變動
$requisition->items()->delete(); $requisition->items()->delete();
$itemsToInsert = [];
foreach ($items as $item) { foreach ($items as $item) {
$requisition->items()->create([ $itemsToInsert[] = [
'store_requisition_id' => $requisition->id,
'product_id' => $item['product_id'], 'product_id' => $item['product_id'],
'requested_qty' => $item['requested_qty'], 'requested_qty' => $item['requested_qty'],
'remark' => $item['remark'] ?? null, 'remark' => $item['remark'] ?? null,
]); 'created_at' => now(),
'updated_at' => now(),
];
} }
StoreRequisitionItem::insert($itemsToInsert);
// 檢查是否有任何變動 (主表或明細) // 檢查是否有任何變動 (主表或明細)
$isDirty = $requisition->isDirty(); $isDirty = $requisition->isDirty();
@@ -314,6 +327,7 @@ class StoreRequisitionService
$supplyWarehouseId = $requisition->supply_warehouse_id; $supplyWarehouseId = $requisition->supply_warehouse_id;
$totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId) $totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId)
->where('product_id', $reqItem->product_id) ->where('product_id', $reqItem->product_id)
->lockForUpdate() // 補上鎖定
->selectRaw('SUM(quantity - reserved_quantity) as available') ->selectRaw('SUM(quantity - reserved_quantity) as available')
->value('available') ?? 0; ->value('available') ?? 0;

View File

@@ -74,11 +74,12 @@ class TransferService
return [$key => $item]; return [$key => $item];
}); });
// 釋放舊明細的預扣庫存 // 釋放舊明細的預扣庫存 (必須加鎖,防止並發更新時數量出錯)
foreach ($order->items as $item) { foreach ($order->items as $item) {
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id) $inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
->where('product_id', $item->product_id) ->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number) ->where('batch_number', $item->batch_number)
->lockForUpdate()
->first(); ->first();
if ($inv) { if ($inv) {
$inv->releaseReservedQuantity($item->quantity); $inv->releaseReservedQuantity($item->quantity);
@@ -91,42 +92,69 @@ class TransferService
'updated' => [], 'updated' => [],
]; ];
// 先刪除舊明細
$order->items()->delete(); $order->items()->delete();
$itemsToInsert = [];
$newItemsKeys = []; $newItemsKeys = [];
// 1. 批量收集待插入的明細數據
foreach ($itemsData as $data) { foreach ($itemsData as $data) {
$key = $data['product_id'] . '_' . ($data['batch_number'] ?? ''); $key = $data['product_id'] . '_' . ($data['batch_number'] ?? '');
$newItemsKeys[] = $key; $newItemsKeys[] = $key;
$item = $order->items()->create([ $itemsToInsert[] = [
'transfer_order_id' => $order->id,
'product_id' => $data['product_id'], 'product_id' => $data['product_id'],
'batch_number' => $data['batch_number'] ?? null, 'batch_number' => $data['batch_number'] ?? null,
'quantity' => $data['quantity'], 'quantity' => $data['quantity'],
'position' => $data['position'] ?? null, 'position' => $data['position'] ?? null,
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); 'created_at' => now(),
$item->load('product'); 'updated_at' => now(),
];
}
// 增加新明細的預扣庫存 // 2. 執行批量寫入 (提升效能100 筆明細只需 1 次寫入)
$inv = Inventory::firstOrCreate( 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, 'warehouse_id' => $order->from_warehouse_id,
'product_id' => $item->product_id, 'product_id' => $item->product_id,
'batch_number' => $item->batch_number, 'batch_number' => $item->batch_number,
],
[
'quantity' => 0, 'quantity' => 0,
'unit_cost' => 0, 'unit_cost' => 0,
'total_value' => 0, 'total_value' => 0,
] ]);
); $inv = $inv->fresh()->lockForUpdate();
}
$inv->reserveQuantity($item->quantity); $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)) { if ($oldItemsMap->has($key)) {
$oldItem = $oldItemsMap->get($key); $oldItem = $oldItemsMap->get($key);
if ((float)$oldItem->quantity !== (float)$data['quantity'] || if ((float)$oldItem->quantity !== (float)$item->quantity ||
$oldItem->notes !== ($data['notes'] ?? null) || $oldItem->notes !== $item->notes ||
$oldItem->position !== ($data['position'] ?? null)) { $oldItem->position !== $item->position) {
$diff['updated'][] = [ $diff['updated'][] = [
'product_name' => $item->product->name, 'product_name' => $item->product->name,
@@ -137,7 +165,7 @@ class TransferService
'notes' => $oldItem->notes, 'notes' => $oldItem->notes,
], ],
'new' => [ 'new' => [
'quantity' => (float)$data['quantity'], 'quantity' => (float)$item->quantity,
'position' => $item->position, 'position' => $item->position,
'notes' => $item->notes, 'notes' => $item->notes,
] ]
@@ -158,8 +186,8 @@ class TransferService
foreach ($oldItemsMap as $key => $oldItem) { foreach ($oldItemsMap as $key => $oldItem) {
if (!in_array($key, $newItemsKeys)) { if (!in_array($key, $newItemsKeys)) {
$diff['removed'][] = [ $diff['removed'][] = [
'product_name' => $oldItem->product->name, 'product_name' => $oldItem->product?->name ?? "未知商品 (ID: {$oldItem->product_id})",
'unit_name' => $oldItem->product->baseUnit?->name, 'unit_name' => $oldItem->product?->baseUnit?->name,
'old' => [ 'old' => [
'quantity' => (float)$oldItem->quantity, 'quantity' => (float)$oldItem->quantity,
'notes' => $oldItem->notes, 'notes' => $oldItem->notes,
@@ -179,9 +207,6 @@ class TransferService
/** /**
* 出貨 (Dispatch) - 根據是否有在途倉決定流程 * 出貨 (Dispatch) - 根據是否有在途倉決定流程
*
* 有在途倉:來源倉扣除 在途倉增加,狀態改為 dispatched
* 無在途倉:來源倉扣除 目的倉增加,狀態改為 completed維持原有邏輯
*/ */
public function dispatch(InventoryTransferOrder $order, int $userId): void public function dispatch(InventoryTransferOrder $order, int $userId): void
{ {
@@ -194,18 +219,16 @@ class TransferService
$targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id; $targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id;
$targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse; $targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse;
$outType = '調撥出庫';
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
$itemsDiff = []; $itemsDiff = [];
foreach ($order->items as $item) { foreach ($order->items as $item) {
if ($item->quantity <= 0) continue; if ($item->quantity <= 0) continue;
// 1. 處理來源倉 (扣除) // 1. 處理來源倉 (扣除) - 使用 lockForUpdate 防止超賣
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id) $sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
->where('product_id', $item->product_id) ->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number) ->where('batch_number', $item->batch_number)
->lockForUpdate()
->first(); ->first();
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) { if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
@@ -235,11 +258,11 @@ class TransferService
$sourceAfter = $sourceBefore - (float) $item->quantity; $sourceAfter = $sourceBefore - (float) $item->quantity;
// 2. 處理目的倉/在途倉 (增加) // 2. 處理目的倉/在途倉 (增加) - 同樣需要鎖定,防止並發增加時出現 Race Condition
// 獲取目的倉異動前的庫存數(若無則為 0
$targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId) $targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId)
->where('product_id', $item->product_id) ->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number) ->where('batch_number', $item->batch_number)
->lockForUpdate()
->first(); ->first();
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0; $targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
@@ -310,7 +333,6 @@ class TransferService
/** /**
* 收貨確認 (Receive) - 在途倉扣除 目的倉增加 * 收貨確認 (Receive) - 在途倉扣除 目的倉增加
* 僅適用於有在途倉且狀態為 dispatched 的調撥單
*/ */
public function receive(InventoryTransferOrder $order, int $userId): void public function receive(InventoryTransferOrder $order, int $userId): void
{ {
@@ -333,10 +355,11 @@ class TransferService
foreach ($order->items as $item) { foreach ($order->items as $item) {
if ($item->quantity <= 0) continue; if ($item->quantity <= 0) continue;
// 1. 在途倉扣除 // 1. 在途倉扣除 - 使用 lockForUpdate 防止超賣
$transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id) $transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id)
->where('product_id', $item->product_id) ->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number) ->where('batch_number', $item->batch_number)
->lockForUpdate()
->first(); ->first();
if (!$transitInventory || $transitInventory->quantity < $item->quantity) { if (!$transitInventory || $transitInventory->quantity < $item->quantity) {
@@ -359,10 +382,11 @@ class TransferService
$transitAfter = $transitBefore - (float) $item->quantity; $transitAfter = $transitBefore - (float) $item->quantity;
// 2. 目的倉增加 // 2. 目的倉增加 - 同樣需要鎖定
$targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id) $targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id)
->where('product_id', $item->product_id) ->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number) ->where('batch_number', $item->batch_number)
->lockForUpdate()
->first(); ->first();
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0; $targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
@@ -440,6 +464,7 @@ class TransferService
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id) $inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
->where('product_id', $item->product_id) ->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number) ->where('batch_number', $item->batch_number)
->lockForUpdate()
->first(); ->first();
if ($inv) { if ($inv) {
$inv->releaseReservedQuantity($item->quantity); $inv->releaseReservedQuantity($item->quantity);

View File

@@ -69,6 +69,12 @@ class TurnoverService
->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d')) ->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d'))
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->where('inventory_transactions.type', '出庫') // Adjust type as needed based on actual data ->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) ->where('inventory_transactions.actual_time', '>=', $thirtyDaysAgo)
->groupBy('inventories.product_id'); ->groupBy('inventories.product_id');
@@ -87,6 +93,12 @@ class TurnoverService
->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date')) ->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date'))
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->where('inventory_transactions.type', '出庫') ->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'); ->groupBy('inventories.product_id');
if ($warehouseId) { if ($warehouseId) {
@@ -199,6 +211,12 @@ class TurnoverService
// Get IDs of products sold in last 90 days // Get IDs of products sold in last 90 days
$soldProductIds = InventoryTransaction::query() $soldProductIds = InventoryTransaction::query()
->where('type', '出庫') ->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) ->where('actual_time', '>=', $ninetyDaysAgo)
->distinct() ->distinct()
->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product. ->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product.
@@ -214,6 +232,12 @@ class TurnoverService
$soldProductIdsQuery = DB::table('inventory_transactions') $soldProductIdsQuery = DB::table('inventory_transactions')
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->where('inventory_transactions.type', '出庫') ->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) ->where('inventory_transactions.actual_time', '>=', $ninetyDaysAgo)
->select('inventories.product_id') ->select('inventories.product_id')
->distinct(); ->distinct();
@@ -236,6 +260,12 @@ class TurnoverService
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->join('products', 'inventories.product_id', '=', 'products.id') ->join('products', 'inventories.product_id', '=', 'products.id')
->where('inventory_transactions.type', '出庫') ->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)) ->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays($analysisDays))
->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId)) ->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId))
->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId)) ->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId))

View File

@@ -118,7 +118,7 @@ class PurchaseOrderController extends Controller
public function create() public function create()
{ {
// 1. 獲取廠商(無關聯) // 1. 獲取廠商(無關聯)
$vendors = Vendor::all(); $vendors = Vendor::select('id', 'name')->get();
// 2. 手動注入:獲取 Pivot 資料 // 2. 手動注入:獲取 Pivot 資料
$vendorIds = $vendors->pluck('id')->toArray(); $vendorIds = $vendors->pluck('id')->toArray();
@@ -254,17 +254,21 @@ class PurchaseOrderController extends Controller
$productIds = collect($validated['items'])->pluck('productId')->unique()->toArray(); $productIds = collect($validated['items'])->pluck('productId')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$itemsToInsert = [];
foreach ($validated['items'] as $item) { foreach ($validated['items'] as $item) {
// 反算單價 // 反算單價
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0; $unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
$order->items()->create([ $itemsToInsert[] = [
'purchase_order_id' => $order->id,
'product_id' => $item['productId'], 'product_id' => $item['productId'],
'quantity' => $item['quantity'], 'quantity' => $item['quantity'],
'unit_id' => $item['unitId'] ?? null, 'unit_id' => $item['unitId'] ?? null,
'unit_price' => $unitPrice, 'unit_price' => $unitPrice,
'subtotal' => $item['subtotal'], 'subtotal' => $item['subtotal'],
]); 'created_at' => now(),
'updated_at' => now(),
];
$product = $products->get($item['productId']); $product = $products->get($item['productId']);
$diff['added'][] = [ $diff['added'][] = [
@@ -275,6 +279,7 @@ class PurchaseOrderController extends Controller
] ]
]; ];
} }
\App\Modules\Procurement\Models\PurchaseOrderItem::insert($itemsToInsert);
// 手動發送高品質日誌(包含品項明細) // 手動發送高品質日誌(包含品項明細)
activity() activity()
@@ -379,7 +384,7 @@ class PurchaseOrderController extends Controller
$order = PurchaseOrder::with(['items'])->findOrFail($id); $order = PurchaseOrder::with(['items'])->findOrFail($id);
// 2. 獲取廠商與商品(與 create 邏輯一致) // 2. 獲取廠商與商品(與 create 邏輯一致)
$vendors = Vendor::all(); $vendors = Vendor::select('id', 'name')->get();
$vendorIds = $vendors->pluck('id')->toArray(); $vendorIds = $vendors->pluck('id')->toArray();
$pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get(); $pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get();
$productIds = $pivots->pluck('product_id')->unique()->toArray(); $productIds = $pivots->pluck('product_id')->unique()->toArray();
@@ -468,7 +473,8 @@ class PurchaseOrderController extends Controller
public function update(Request $request, $id) public function update(Request $request, $id)
{ {
$order = PurchaseOrder::findOrFail($id); // 加上 lockForUpdate() 防止併發修改
$order = PurchaseOrder::lockForUpdate()->findOrFail($id);
$validated = $request->validate([ $validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id', 'vendor_id' => 'required|exists:vendors,id',
@@ -572,20 +578,23 @@ class PurchaseOrderController extends Controller
// 同步項目(原始邏輯) // 同步項目(原始邏輯)
$order->items()->delete(); $order->items()->delete();
$newItemsData = []; $itemsToInsert = [];
foreach ($validated['items'] as $item) { foreach ($validated['items'] as $item) {
// 反算單價 // 反算單價
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0; $unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
$newItem = $order->items()->create([ $itemsToInsert[] = [
'purchase_order_id' => $order->id,
'product_id' => $item['productId'], 'product_id' => $item['productId'],
'quantity' => $item['quantity'], 'quantity' => $item['quantity'],
'unit_id' => $item['unitId'] ?? null, 'unit_id' => $item['unitId'] ?? null,
'unit_price' => $unitPrice, 'unit_price' => $unitPrice,
'subtotal' => $item['subtotal'], 'subtotal' => $item['subtotal'],
]); 'created_at' => now(),
$newItemsData[] = $newItem; 'updated_at' => now(),
];
} }
\App\Modules\Procurement\Models\PurchaseOrderItem::insert($itemsToInsert);
// 3. 計算項目差異 // 3. 計算項目差異
$itemDiffs = [ $itemDiffs = [

View File

@@ -48,7 +48,7 @@ class PurchaseReturnController extends Controller
{ {
// 取得可用的倉庫與廠商資料供前端選單使用 // 取得可用的倉庫與廠商資料供前端選單使用
$warehouses = $this->inventoryService->getAllWarehouses(); $warehouses = $this->inventoryService->getAllWarehouses();
$vendors = Vendor::all(); $vendors = Vendor::select('id', 'name')->get();
// 手動注入:獲取廠商商品 (與 PurchaseOrderController 邏輯一致) // 手動注入:獲取廠商商品 (與 PurchaseOrderController 邏輯一致)
$vendorIds = $vendors->pluck('id')->toArray(); $vendorIds = $vendors->pluck('id')->toArray();
@@ -157,7 +157,7 @@ class PurchaseReturnController extends Controller
}); });
$warehouses = $this->inventoryService->getAllWarehouses(); $warehouses = $this->inventoryService->getAllWarehouses();
$vendors = Vendor::all(); $vendors = Vendor::select('id', 'name')->get();
// 手動注入:獲取廠商商品 (與 create 邏輯一致) // 手動注入:獲取廠商商品 (與 create 邏輯一致)
$vendorIds = $vendors->pluck('id')->toArray(); $vendorIds = $vendors->pluck('id')->toArray();

View File

@@ -33,20 +33,23 @@ class PurchaseReturnService
$purchaseReturn = PurchaseReturn::create($data); $purchaseReturn = PurchaseReturn::create($data);
$itemsToInsert = [];
foreach ($data['items'] as $itemData) { foreach ($data['items'] as $itemData) {
$amount = $itemData['quantity_returned'] * $itemData['unit_price']; $amount = $itemData['quantity_returned'] * $itemData['unit_price'];
$totalAmount += $amount; $totalAmount += $amount;
$prItem = new PurchaseReturnItem([ $itemsToInsert[] = [
'purchase_return_id' => $purchaseReturn->id,
'product_id' => $itemData['product_id'], 'product_id' => $itemData['product_id'],
'quantity_returned' => $itemData['quantity_returned'], 'quantity_returned' => $itemData['quantity_returned'],
'unit_price' => $itemData['unit_price'], 'unit_price' => $itemData['unit_price'],
'total_amount' => $amount, 'total_amount' => $amount,
'batch_number' => $itemData['batch_number'] ?? null, 'batch_number' => $itemData['batch_number'] ?? null,
]); 'created_at' => now(),
'updated_at' => now(),
$purchaseReturn->items()->save($prItem); ];
} }
PurchaseReturnItem::insert($itemsToInsert);
// 更新總計 (這裡假定不含額外稅金邏輯,或是由前端帶入 tax_amount) // 更新總計 (這裡假定不含額外稅金邏輯,或是由前端帶入 tax_amount)
$taxAmount = $data['tax_amount'] ?? 0; $taxAmount = $data['tax_amount'] ?? 0;
@@ -87,19 +90,23 @@ class PurchaseReturnService
$purchaseReturn->items()->delete(); $purchaseReturn->items()->delete();
$totalAmount = 0; $totalAmount = 0;
$itemsToInsert = [];
foreach ($data['items'] as $itemData) { foreach ($data['items'] as $itemData) {
$amount = $itemData['quantity_returned'] * $itemData['unit_price']; $amount = $itemData['quantity_returned'] * $itemData['unit_price'];
$totalAmount += $amount; $totalAmount += $amount;
$prItem = new PurchaseReturnItem([ $itemsToInsert[] = [
'purchase_return_id' => $purchaseReturn->id,
'product_id' => $itemData['product_id'], 'product_id' => $itemData['product_id'],
'quantity_returned' => $itemData['quantity_returned'], 'quantity_returned' => $itemData['quantity_returned'],
'unit_price' => $itemData['unit_price'], 'unit_price' => $itemData['unit_price'],
'total_amount' => $amount, 'total_amount' => $amount,
'batch_number' => $itemData['batch_number'] ?? null, 'batch_number' => $itemData['batch_number'] ?? null,
]); 'created_at' => now(),
$purchaseReturn->items()->save($prItem); 'updated_at' => now(),
];
} }
PurchaseReturnItem::insert($itemsToInsert);
$taxAmount = $purchaseReturn->tax_amount; $taxAmount = $purchaseReturn->tax_amount;
$purchaseReturn->update([ $purchaseReturn->update([
@@ -117,11 +124,14 @@ class PurchaseReturnService
*/ */
public function submit(PurchaseReturn $purchaseReturn) public function submit(PurchaseReturn $purchaseReturn)
{ {
if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) {
throw new Exception('只有草稿狀態的退回單可以提交。');
}
return DB::transaction(function () use ($purchaseReturn) { return DB::transaction(function () use ($purchaseReturn) {
// 加上 lockForUpdate() 防止併發提交
$purchaseReturn = PurchaseReturn::lockForUpdate()->find($purchaseReturn->id);
if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) {
throw new Exception('只有草稿狀態的退回單可以提交。');
}
// 1. 儲存狀態,避免觸發自動修改紀錄 (合併行為) // 1. 儲存狀態,避免觸發自動修改紀錄 (合併行為)
$purchaseReturn->status = PurchaseReturn::STATUS_COMPLETED; $purchaseReturn->status = PurchaseReturn::STATUS_COMPLETED;
$purchaseReturn->saveQuietly(); $purchaseReturn->saveQuietly();

View File

@@ -137,12 +137,12 @@ class ProductionOrderController extends Controller
$rules = [ $rules = [
'product_id' => 'required', 'product_id' => 'required',
'status' => 'nullable|in:draft,completed', 'status' => 'nullable|in:draft,pending,completed',
'warehouse_id' => $status === 'completed' ? 'required' : 'nullable', 'warehouse_id' => 'required',
'output_quantity' => $status === 'completed' ? 'required|numeric|min:0.01' : 'nullable|numeric', 'output_quantity' => 'required|numeric|min:0.01',
'items' => 'nullable|array', 'items' => 'nullable|array',
'items.*.inventory_id' => $status === 'completed' ? 'required' : 'nullable', 'items.*.inventory_id' => 'required',
'items.*.quantity_used' => $status === 'completed' ? 'required|numeric|min:0.0001' : 'nullable|numeric', 'items.*.quantity_used' => 'required|numeric|min:0.0001',
]; ];
$validated = $request->validate($rules); $validated = $request->validate($rules);
@@ -159,7 +159,7 @@ class ProductionOrderController extends Controller
'production_date' => $request->production_date, 'production_date' => $request->production_date,
'expiry_date' => $request->expiry_date, 'expiry_date' => $request->expiry_date,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿 'status' => $status ?: ProductionOrder::STATUS_DRAFT,
'remark' => $request->remark, 'remark' => $request->remark,
]); ]);
@@ -170,14 +170,18 @@ class ProductionOrderController extends Controller
// 2. 處理明細 // 2. 處理明細
if (!empty($request->items)) { if (!empty($request->items)) {
$itemsToInsert = [];
foreach ($request->items as $item) { foreach ($request->items as $item) {
ProductionOrderItem::create([ $itemsToInsert[] = [
'production_order_id' => $productionOrder->id, 'production_order_id' => $productionOrder->id,
'inventory_id' => $item['inventory_id'], 'inventory_id' => $item['inventory_id'],
'quantity_used' => $item['quantity_used'] ?? 0, 'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null, '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(); $productionOrder->items()->delete();
if (!empty($request->items)) { if (!empty($request->items)) {
$itemsToInsert = [];
foreach ($request->items as $item) { foreach ($request->items as $item) {
ProductionOrderItem::create([ $itemsToInsert[] = [
'production_order_id' => $productionOrder->id, 'production_order_id' => $productionOrder->id,
'inventory_id' => $item['inventory_id'], 'inventory_id' => $item['inventory_id'],
'quantity_used' => $item['quantity_used'] ?? 0, 'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null, '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); 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) { DB::transaction(function () use ($newStatus, $productionOrder, $request) {
// 使用鎖定重新獲取單據,防止併發狀態修改
$productionOrder = ProductionOrder::where('id', $productionOrder->id)->lockForUpdate()->first();
$oldStatus = $productionOrder->status; $oldStatus = $productionOrder->status;
// 再次檢查狀態轉移(在鎖定後)
if (!$productionOrder->canTransitionTo($newStatus)) {
throw new \Exception('不合法的狀態轉移或權限不足');
}
// 1. 執行特定狀態的業務邏輯 // 1. 執行特定狀態的業務邏輯
if ($oldStatus === ProductionOrder::STATUS_APPROVED && $newStatus === ProductionOrder::STATUS_IN_PROGRESS) { if ($oldStatus === ProductionOrder::STATUS_APPROVED && $newStatus === ProductionOrder::STATUS_IN_PROGRESS) {
// 開始製作 -> 扣除原料庫存 // 開始製作 -> 扣除原料庫存
@@ -428,6 +457,8 @@ class ProductionOrderController extends Controller
$warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來 $warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來
$batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來 $batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來
$expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來 $expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來
$actualOutputQuantity = $request->input('actual_output_quantity'); // 實際產出數量
$lossReason = $request->input('loss_reason'); // 耗損原因
if (!$warehouseId) { if (!$warehouseId) {
throw new \Exception('必須選擇入庫倉庫'); throw new \Exception('必須選擇入庫倉庫');
@@ -435,8 +466,14 @@ class ProductionOrderController extends Controller
if (!$batchNumber) { if (!$batchNumber) {
throw new \Exception('必須提供成品批號'); throw new \Exception('必須提供成品批號');
} }
if (!$actualOutputQuantity || $actualOutputQuantity <= 0) {
throw new \Exception('實際產出數量必須大於 0');
}
if ($actualOutputQuantity > $productionOrder->output_quantity) {
throw new \Exception('實際產出數量不可大於預計產量');
}
// --- 新增:計算原物料投入總成本 --- // --- 計算原物料投入總成本 ---
$totalCost = 0; $totalCost = 0;
$items = $productionOrder->items()->with('inventory')->get(); $items = $productionOrder->items()->with('inventory')->get();
foreach ($items as $item) { foreach ($items as $item) {
@@ -445,23 +482,25 @@ class ProductionOrderController extends Controller
} }
} }
// 計算單位成本 (若產出數量為 0 則設為 0 避免除以零錯誤) // 單位成本以「實際產出數量」為分母,反映真實生產效率
$unitCost = $productionOrder->output_quantity > 0 $unitCost = $actualOutputQuantity > 0
? $totalCost / $productionOrder->output_quantity ? $totalCost / $actualOutputQuantity
: 0; : 0;
// --------------------------------
// 更新單據資訊:批號、效期與自動記錄生產日期 // 更新單據資訊:批號、效期、實際產量與耗損原因
$productionOrder->output_batch_number = $batchNumber; $productionOrder->output_batch_number = $batchNumber;
$productionOrder->expiry_date = $expiryDate; $productionOrder->expiry_date = $expiryDate;
$productionOrder->production_date = now()->toDateString(); $productionOrder->production_date = now()->toDateString();
$productionOrder->warehouse_id = $warehouseId; $productionOrder->warehouse_id = $warehouseId;
$productionOrder->actual_output_quantity = $actualOutputQuantity;
$productionOrder->loss_reason = $lossReason;
// 成品入庫數量改用「實際產出數量」
$this->inventoryService->createInventoryRecord([ $this->inventoryService->createInventoryRecord([
'warehouse_id' => $warehouseId, 'warehouse_id' => $warehouseId,
'product_id' => $productionOrder->product_id, 'product_id' => $productionOrder->product_id,
'quantity' => $productionOrder->output_quantity, 'quantity' => $actualOutputQuantity,
'unit_cost' => $unitCost, // 傳入計算後的單位成本 'unit_cost' => $unitCost,
'batch_number' => $batchNumber, 'batch_number' => $batchNumber,
'box_number' => $productionOrder->output_box_count, 'box_number' => $productionOrder->output_box_count,
'arrival_date' => now()->toDateString(), 'arrival_date' => now()->toDateString(),

View File

@@ -24,6 +24,8 @@ class ProductionOrder extends Model
'product_id', 'product_id',
'warehouse_id', 'warehouse_id',
'output_quantity', 'output_quantity',
'actual_output_quantity',
'loss_reason',
'output_batch_number', 'output_batch_number',
'output_box_count', 'output_box_count',
'production_date', 'production_date',
@@ -82,6 +84,7 @@ class ProductionOrder extends Model
'production_date' => 'date', 'production_date' => 'date',
'expiry_date' => 'date', 'expiry_date' => 'date',
'output_quantity' => 'decimal:2', 'output_quantity' => 'decimal:2',
'actual_output_quantity' => 'decimal:2',
]; ];
public function getActivitylogOptions(): LogOptions public function getActivitylogOptions(): LogOptions
@@ -91,6 +94,8 @@ class ProductionOrder extends Model
'code', 'code',
'status', 'status',
'output_quantity', 'output_quantity',
'actual_output_quantity',
'loss_reason',
'output_batch_number', 'output_batch_number',
'production_date', 'production_date',
'remark' 'remark'

View File

@@ -101,11 +101,13 @@ class SalesImportController extends Controller
public function confirm(SalesImportBatch $import, InventoryServiceInterface $inventoryService) public function confirm(SalesImportBatch $import, InventoryServiceInterface $inventoryService)
{ {
if ($import->status !== 'pending') { return DB::transaction(function () use ($import, $inventoryService) {
return back()->with('error', '此批次無法確認。'); // 加上 lockForUpdate() 防止併發確認
} $import = SalesImportBatch::lockForUpdate()->find($import->id);
DB::transaction(function () use ($import, $inventoryService) { if (!$import || $import->status !== 'pending') {
throw new \Exception('此批次無法確認或已被處理。');
}
// 1. Prepare Aggregation // 1. Prepare Aggregation
$aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot" $aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot"
@@ -155,7 +157,9 @@ class SalesImportController extends Controller
$deduction['quantity'], $deduction['quantity'],
$reason, $reason,
true, // Force deduction true, // Force deduction
$deduction['slot'] // Location/Slot $deduction['slot'], // Location/Slot
\App\Modules\Sales\Models\SalesImportBatch::class,
$import->id
); );
} }

View File

@@ -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');
}
};

View File

@@ -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']);
});
}
};

View File

@@ -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']);
});
}
};

34
e2e/admin.spec.ts Normal file
View 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
View 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
View 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
View 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
View 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 是 textboxplaceholder 是 "備註..."
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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
});
});

83
package-lock.json generated
View File

@@ -43,6 +43,7 @@
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
@@ -83,6 +84,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -846,6 +848,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/number": { "node_modules/@radix-ui/number": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -2847,6 +2865,7 @@
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -2856,6 +2875,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -2866,6 +2886,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -3001,6 +3022,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -3285,7 +3307,8 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/d3-array": { "node_modules/d3-array": {
"version": "3.2.4", "version": "3.2.4",
@@ -5379,6 +5402,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -5386,6 +5410,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -5463,6 +5534,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -5475,6 +5547,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -5539,6 +5612,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@@ -5669,7 +5743,8 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@@ -6035,7 +6110,8 @@
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
@@ -6360,6 +6436,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",

View File

@@ -1,59 +1,60 @@
{ {
"$schema": "https://www.schemastore.org/package.json", "$schema": "https://www.schemastore.org/package.json",
"name": "star-erp", "name": "star-erp",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"dev": "vite" "dev": "vite"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@playwright/test": "^1.58.2",
"@types/node": "^25.0.3", "@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.2.7", "@types/node": "^25.0.3",
"@types/react-dom": "^19.2.3", "@types/react": "^19.2.7",
"axios": "^1.11.0", "@types/react-dom": "^19.2.3",
"concurrently": "^9.0.1", "axios": "^1.11.0",
"laravel-vite-plugin": "^2.0.0", "concurrently": "^9.0.1",
"tailwindcss": "^4.0.0", "laravel-vite-plugin": "^2.0.0",
"typescript": "^5.9.3", "tailwindcss": "^4.0.0",
"vite": "^7.0.7" "typescript": "^5.9.3",
}, "vite": "^7.0.7"
"dependencies": { },
"@inertiajs/react": "^2.3.4", "dependencies": {
"@radix-ui/react-accordion": "^1.2.12", "@inertiajs/react": "^2.3.4",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/typography": "^0.5.19", "@radix-ui/react-tooltip": "^1.2.8",
"@types/lodash": "^4.17.21", "@tailwindcss/typography": "^0.5.19",
"@vitejs/plugin-react": "^5.1.2", "@types/lodash": "^4.17.21",
"class-variance-authority": "^0.7.1", "@vitejs/plugin-react": "^5.1.2",
"clsx": "^2.1.1", "class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "cmdk": "^1.1.1",
"jsbarcode": "^3.12.1", "date-fns": "^4.1.0",
"lodash": "^4.17.21", "jsbarcode": "^3.12.1",
"lucide-react": "^0.562.0", "lodash": "^4.17.21",
"react": "^18.3.1", "lucide-react": "^0.562.0",
"react-dom": "^18.3.1", "react": "^18.3.1",
"react-hot-toast": "^2.6.0", "react-dom": "^18.3.1",
"react-markdown": "^10.1.0", "react-hot-toast": "^2.6.0",
"recharts": "^3.7.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "recharts": "^3.7.0",
"sonner": "^2.0.7", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0" "sonner": "^2.0.7",
} "tailwind-merge": "^3.4.0"
}
} }

47
playwright.config.ts Normal file
View 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'] },
},
],
});

View File

@@ -1,5 +1,6 @@
/** /**
* 生產工單完工入庫 - 選擇倉庫彈窗 * 生產工單完工入庫 - 選擇倉庫彈窗
* 含產出確認與耗損記錄功能
*/ */
import React from 'react'; import React from 'react';
@@ -8,7 +9,8 @@ import { Button } from "@/Components/ui/button";
import { SearchableSelect } from "@/Components/ui/searchable-select"; import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label"; 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 { interface Warehouse {
id: number; id: number;
@@ -22,12 +24,18 @@ interface WarehouseSelectionModalProps {
warehouseId: number; warehouseId: number;
batchNumber: string; batchNumber: string;
expiryDate: string; expiryDate: string;
actualOutputQuantity: number;
lossReason: string;
}) => void; }) => void;
warehouses: Warehouse[]; warehouses: Warehouse[];
processing?: boolean; processing?: boolean;
// 新增商品資訊以利產生批號 // 商品資訊用於產生批號
productCode?: string; productCode?: string;
productId?: number; productId?: number;
// 預計產量(用於耗損計算)
outputQuantity: number;
// 成品單位名稱
unitName?: string;
} }
export default function WarehouseSelectionModal({ export default function WarehouseSelectionModal({
@@ -38,10 +46,22 @@ export default function WarehouseSelectionModal({
processing = false, processing = false,
productCode, productCode,
productId, productId,
outputQuantity,
unitName = '',
}: WarehouseSelectionModalProps) { }: WarehouseSelectionModalProps) {
const [selectedId, setSelectedId] = React.useState<number | null>(null); const [selectedId, setSelectedId] = React.useState<number | null>(null);
const [batchNumber, setBatchNumber] = React.useState<string>(""); const [batchNumber, setBatchNumber] = React.useState<string>("");
const [expiryDate, setExpiryDate] = 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(() => { React.useEffect(() => {
@@ -62,26 +82,37 @@ export default function WarehouseSelectionModal({
} }
}, [isOpen, productCode, productId]); }, [isOpen, productCode, productId]);
// 計算耗損數量
const actualQty = parseFloat(actualOutputQuantity) || 0;
const lossQuantity = outputQuantity - actualQty;
const hasLoss = lossQuantity > 0;
const handleConfirm = () => { const handleConfirm = () => {
if (selectedId && batchNumber) { if (selectedId && batchNumber && actualQty > 0) {
onConfirm({ onConfirm({
warehouseId: selectedId, warehouseId: selectedId,
batchNumber, batchNumber,
expiryDate expiryDate,
actualOutputQuantity: actualQty,
lossReason: hasLoss ? lossReason : '',
}); });
} }
}; };
// 驗證:實際產出不可大於預計產量,也不可小於等於 0
const isActualQtyValid = actualQty > 0 && actualQty <= outputQuantity;
return ( return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[480px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-primary-main"> <DialogTitle className="flex items-center gap-2 text-primary-main">
<WarehouseIcon className="h-5 w-5" /> <WarehouseIcon className="h-5 w-5" />
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="py-6 space-y-6"> <div className="py-6 space-y-6">
{/* 倉庫選擇 */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1"> <Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<WarehouseIcon className="h-3 w-3" /> <WarehouseIcon className="h-3 w-3" />
@@ -96,6 +127,7 @@ export default function WarehouseSelectionModal({
/> />
</div> </div>
{/* 成品批號 */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1"> <Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<Tag className="h-3 w-3" /> <Tag className="h-3 w-3" />
@@ -109,6 +141,7 @@ export default function WarehouseSelectionModal({
/> />
</div> </div>
{/* 成品效期 */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1"> <Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<CalendarIcon className="h-3 w-3" /> <CalendarIcon className="h-3 w-3" />
@@ -121,6 +154,64 @@ export default function WarehouseSelectionModal({
className="h-9" className="h-9"
/> />
</div> </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> </div>
<DialogFooter> <DialogFooter>
<Button <Button
@@ -134,7 +225,7 @@ export default function WarehouseSelectionModal({
</Button> </Button>
<Button <Button
onClick={handleConfirm} onClick={handleConfirm}
disabled={!selectedId || !batchNumber || processing} disabled={!selectedId || !batchNumber || !isActualQtyValid || processing}
className="gap-2 button-filled-primary" className="gap-2 button-filled-primary"
> >
<CheckCircle2 className="h-4 w-4" /> <CheckCircle2 className="h-4 w-4" />

View 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>
</>
);
}

View File

@@ -20,13 +20,19 @@ import { validateInvoiceNumber } from "@/utils/validation";
export interface UtilityFee { export interface UtilityFee {
id: number; id: number;
transaction_date: string | null; billing_month: string;
due_date: string; category_id: number;
category: string; category?: string; // 相容於舊版或特定視圖
category_name?: string;
amount: number | 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; invoice_number?: string;
description?: string; description?: string;
attachments_count?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }

View File

@@ -266,14 +266,13 @@ export default function WarehouseDialog({
{/* 倉庫地址 */} {/* 倉庫地址 */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="address"> <Label htmlFor="address">
<span className="text-red-500">*</span>
</Label> </Label>
<Input <Input
id="address" id="address"
value={formData.address} value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })} onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="例台北市信義區信義路五段7號" placeholder="例台北市信義區信義路五段7號"
required
className="h-9" className="h-9"
/> />
</div> </div>

View File

@@ -27,22 +27,33 @@ export default function Pagination({ links, className }: PaginationProps) {
const isNext = label === "Next"; const isNext = label === "Next";
const activeIndex = links.findIndex(l => l.active); const activeIndex = links.findIndex(l => l.active);
// Tablet/Mobile visibility logic (< md): // Responsive visibility logic:
// Show: Previous, Next, Active, and up to 2 neighbors (Total ~5 numeric pages) // Global: Previous, Next, Active are always visible
// Hide others on small screens (hidden md:flex) // Mobile (< sm): Active, +-1, First, Last, and Ellipses
// User requested: "small than 800... display 5 pages" // Tablet (sm < md): Active, +-2, First, Last, and Ellipses
const isVisibleOnTablet = // Desktop (>= md): All standard pages
const isFirst = key === 1;
const isLast = key === links.length - 2;
const isEllipsis = !isPrevious && !isNext && !link.url;
const isMobileVisible =
isPrevious || isPrevious ||
isNext || isNext ||
link.active || link.active ||
isFirst ||
isLast ||
isEllipsis ||
key === activeIndex - 1 || key === activeIndex - 1 ||
key === activeIndex + 1 || key === activeIndex + 1;
const isTabletVisible =
isMobileVisible ||
key === activeIndex - 2 || key === activeIndex - 2 ||
key === activeIndex + 2; key === activeIndex + 2;
const baseClasses = cn( 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 // 如果是 Previous/Next 但沒有 URL則不渲染或者渲染為 disabled

View File

@@ -38,6 +38,8 @@ interface SearchableSelectProps {
showSearch?: boolean; showSearch?: boolean;
/** 是否可清除選取 */ /** 是否可清除選取 */
isClearable?: boolean; isClearable?: boolean;
/** 是否為無效狀態(顯示紅色邊框) */
"aria-invalid"?: boolean;
} }
export function SearchableSelect({ export function SearchableSelect({
@@ -52,6 +54,7 @@ export function SearchableSelect({
searchThreshold = 10, searchThreshold = 10,
showSearch, showSearch,
isClearable = false, isClearable = false,
"aria-invalid": ariaInvalid,
}: SearchableSelectProps) { }: SearchableSelectProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
@@ -79,12 +82,15 @@ export function SearchableSelect({
!selectedOption && "text-grey-3", !selectedOption && "text-grey-3",
// Focus state - primary border with ring // Focus state - primary border with ring
"focus-visible:border-primary-main focus-visible:ring-primary-main/20 focus-visible:ring-[3px]", "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 state
"disabled:border-grey-4 disabled:bg-background-light-grey disabled:text-grey-2 disabled:cursor-not-allowed disabled:opacity-50", "disabled:border-grey-4 disabled:bg-background-light-grey disabled:text-grey-2 disabled:cursor-not-allowed disabled:opacity-50",
// Height // Height
"h-9", "h-9",
className className
)} )}
aria-invalid={ariaInvalid}
> >
<span className="truncate"> <span className="truncate">
{selectedOption ? selectedOption.label : placeholder} {selectedOption ? selectedOption.label : placeholder}

View File

@@ -8,7 +8,7 @@ import {
Calendar, Calendar,
Filter, Filter,
TrendingDown, TrendingDown,
Package, Wallet,
Pocket, Pocket,
RotateCcw, RotateCcw,
FileText FileText
@@ -29,6 +29,7 @@ import Pagination from "@/Components/shared/Pagination";
import { SearchableSelect } from "@/Components/ui/searchable-select"; import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Can } from "@/Components/Permission/Can"; import { Can } from "@/Components/Permission/Can";
import { Checkbox } from "@/Components/ui/checkbox"; import { Checkbox } from "@/Components/ui/checkbox";
import { StatusBadge } from "@/Components/shared/StatusBadge";
interface Record { interface Record {
id: string; id: string;
@@ -37,8 +38,14 @@ interface Record {
category: string; category: string;
item: string; item: string;
reference: string; reference: string;
invoice_number?: string; invoice_date?: string | null;
invoice_number?: string | null;
amount: number | string; amount: number | string;
tax_amount: number | string;
status?: string;
payment_method?: string | null;
payment_note?: string | null;
remarks?: string | null;
} }
interface PageProps { interface PageProps {
@@ -52,7 +59,7 @@ interface PageProps {
}; };
summary: { summary: {
total_amount: number; total_amount: number;
purchase_total: number; payable_total: number;
utility_total: number; utility_total: number;
record_count: number; record_count: number;
}; };
@@ -273,10 +280,10 @@ export default function AccountingReport({ records, summary, filters }: PageProp
</div> </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"> <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"> <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-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-xl font-bold text-gray-900 truncate">$ {Number(summary.payable_total).toLocaleString()}</span>
</div> </div>
</div> </div>
@@ -305,13 +312,16 @@ export default function AccountingReport({ records, summary, filters }: PageProp
<TableHead className="w-[120px] text-center"></TableHead> <TableHead className="w-[120px] text-center"></TableHead>
<TableHead className="w-[140px] text-center"></TableHead> <TableHead className="w-[140px] text-center"></TableHead>
<TableHead className="px-6"></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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{records.data.length === 0 ? ( {records.data.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6}> <TableCell colSpan={7}>
<div className="flex flex-col items-center justify-center space-y-2 py-8 text-gray-400"> <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" /> <FileText className="h-10 w-10 opacity-20" />
<p></p> <p></p>
@@ -333,7 +343,7 @@ export default function AccountingReport({ records, summary, filters }: PageProp
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Badge variant="secondary" className={ <Badge variant="secondary" className={
record.source === '採購單' record.source === '應付帳款'
? 'bg-orange-50 text-orange-700 border-orange-100' ? 'bg-orange-50 text-orange-700 border-orange-100'
: 'bg-blue-50 text-blue-700 border-blue-100' : 'bg-blue-50 text-blue-700 border-blue-100'
}> }>
@@ -348,11 +358,47 @@ export default function AccountingReport({ records, summary, filters }: PageProp
<TableCell> <TableCell>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-gray-900">{record.item}</span> <span className="font-medium text-gray-900">{record.item}</span>
{record.invoice_number && ( {(record.invoice_number || record.invoice_date) && (
<span className="text-xs text-gray-400">{record.invoice_number}</span> <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> </div>
</TableCell> </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"> <TableCell className="text-right font-bold text-gray-900 px-4">
$ {Number(record.amount).toLocaleString()} $ {Number(record.amount).toLocaleString()}
</TableCell> </TableCell>

View File

@@ -318,10 +318,10 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
from={activities.from} from={activities.from}
/> />
<div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-4"> <div className="mt-6 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center 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"> <div className="flex items-center gap-2 text-sm text-gray-500">
<span></span> <span className="shrink-0"></span>
<SearchableSelect <SearchableSelect
value={perPage} value={perPage}
onValueChange={handlePerPageChange} onValueChange={handlePerPageChange}
@@ -334,11 +334,11 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
className="w-[100px] h-8" className="w-[100px] h-8"
showSearch={false} showSearch={false}
/> />
<span></span> <span className="shrink-0"></span>
</div> </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>
<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} /> <Pagination links={activities.links} />
</div> </div>
</div> </div>

View File

@@ -96,11 +96,11 @@ export default function Create({ products, warehouses }: Props) {
const [recipes, setRecipes] = useState<any[]>([]); const [recipes, setRecipes] = useState<any[]>([]);
const [selectedRecipeId, setSelectedRecipeId] = useState<string>(""); const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
const { data, setData, processing, errors } = useForm({ // 提交表單
const { data, setData, processing, errors, setError, clearErrors } = useForm({
product_id: "", product_id: "",
warehouse_id: "", warehouse_id: "",
output_quantity: "", output_quantity: "",
// 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄
remark: "", remark: "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], 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) => { const fetchProductInventories = async (productId: string) => {
if (!productId) return; if (!productId) return;
// 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
if (loadingProducts[productId]) return; if (loadingProducts[productId]) return;
setLoadingProducts(prev => ({ ...prev, [productId]: true })); setLoadingProducts(prev => ({ ...prev, [productId]: true }));
@@ -159,7 +158,6 @@ export default function Create({ products, warehouses }: Props) {
item.unit_id = ""; item.unit_id = "";
item.ui_input_quantity = ""; item.ui_input_quantity = "";
item.ui_selected_unit = "base"; item.ui_selected_unit = "base";
// 清除 cache 資訊
delete item.ui_product_name; delete item.ui_product_name;
delete item.ui_batch_number; delete item.ui_batch_number;
delete item.ui_available_qty; delete item.ui_available_qty;
@@ -180,21 +178,13 @@ export default function Create({ products, warehouses }: Props) {
} }
} }
// 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量)
if (field === 'ui_warehouse_id') { if (field === 'ui_warehouse_id') {
item.inventory_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_batch_number;
delete item.ui_available_qty; delete item.ui_available_qty;
delete item.ui_expiry_date; delete item.ui_expiry_date;
} }
// 3. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
if (field === 'inventory_id' && value) { if (field === 'inventory_id' && value) {
const currentOptions = productInventoryMap[item.ui_product_id] || []; const currentOptions = productInventoryMap[item.ui_product_id] || [];
const inv = currentOptions.find(i => String(i.id) === value); 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_batch_number = inv.batch_number;
item.ui_available_qty = inv.quantity; item.ui_available_qty = inv.quantity;
item.ui_expiry_date = inv.expiry_date || ''; item.ui_expiry_date = inv.expiry_date || '';
// 單位與轉換率
item.ui_base_unit_name = inv.unit_name || ''; item.ui_base_unit_name = inv.unit_name || '';
item.ui_base_unit_id = inv.base_unit_id; item.ui_base_unit_id = inv.base_unit_id;
item.ui_large_unit_id = inv.large_unit_id; item.ui_large_unit_id = inv.large_unit_id;
item.ui_purchase_unit_id = inv.purchase_unit_id; item.ui_purchase_unit_id = inv.purchase_unit_id;
item.ui_conversion_rate = inv.conversion_rate || 1; item.ui_conversion_rate = inv.conversion_rate || 1;
item.ui_unit_cost = inv.unit_cost || 0; item.ui_unit_cost = inv.unit_cost || 0;
// 預設單位
item.ui_selected_unit = 'base'; item.ui_selected_unit = 'base';
item.unit_id = String(inv.base_unit_id || ''); item.unit_id = String(inv.base_unit_id || '');
// 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方)
if (!item.ui_input_quantity) { if (!item.ui_input_quantity) {
item.ui_input_quantity = formatQuantity(inv.quantity); item.ui_input_quantity = formatQuantity(inv.quantity);
} }
} }
} }
// 4. 計算最終數量 (Base Quantity)
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') { if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
const inputQty = parseFloat(item.ui_input_quantity || '0'); const inputQty = parseFloat(item.ui_input_quantity || '0');
const rate = item.ui_conversion_rate || 1; const rate = item.ui_conversion_rate || 1;
item.quantity_used = item.ui_selected_unit === 'large' ? String(inputQty * rate) : String(inputQty);
if (item.ui_selected_unit === 'large') { item.unit_id = String(item.ui_base_unit_id || '');
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 || '');
}
} }
updated[index] = item; updated[index] = item;
setBomItems(updated); setBomItems(updated);
}; };
// 同步 BOM items 到表單 data
useEffect(() => { useEffect(() => {
setData('items', bomItems.map(item => ({ setData('items', bomItems.map(item => ({
inventory_id: Number(item.inventory_id), inventory_id: Number(item.inventory_id),
@@ -250,33 +226,22 @@ export default function Create({ products, warehouses }: Props) {
}))); })));
}, [bomItems]); }, [bomItems]);
// 應用配方到表單 (獨立函式)
const applyRecipe = (recipe: any) => { const applyRecipe = (recipe: any) => {
if (!recipe || !recipe.items) return; if (!recipe || !recipe.items) return;
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1; const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
// 自動帶入配方標準產量
setData('output_quantity', formatQuantity(yieldQty)); setData('output_quantity', formatQuantity(yieldQty));
const newBomItems: BomItem[] = recipe.items.map((item: any) => { const newBomItems: BomItem[] = recipe.items.map((item: any) => {
const baseQty = parseFloat(item.quantity || "0"); if (item.product_id) fetchProductInventories(String(item.product_id));
const calculatedQty = baseQty; // 保持精度
// 若有配方商品,預先載入庫存分佈
if (item.product_id) {
fetchProductInventories(String(item.product_id));
}
return { return {
inventory_id: "", inventory_id: "",
quantity_used: String(calculatedQty), quantity_used: String(item.quantity || "0"),
unit_id: String(item.unit_id), unit_id: String(item.unit_id),
ui_warehouse_id: "", ui_warehouse_id: "",
ui_product_id: String(item.product_id), ui_product_id: String(item.product_id),
ui_product_name: item.product_name, ui_product_name: item.product_name,
ui_batch_number: "", ui_batch_number: "",
ui_available_qty: 0, ui_available_qty: 0,
ui_input_quantity: formatQuantity(calculatedQty), ui_input_quantity: formatQuantity(item.quantity || "0"),
ui_selected_unit: 'base', ui_selected_unit: 'base',
ui_base_unit_name: item.unit_name, ui_base_unit_name: item.unit_name,
ui_base_unit_id: item.unit_id, ui_base_unit_id: item.unit_id,
@@ -284,45 +249,30 @@ export default function Create({ products, warehouses }: Props) {
}; };
}); });
setBomItems(newBomItems); setBomItems(newBomItems);
toast.success(`已自動載入配方: ${recipe.name}`);
toast.success(`已自動載入配方: ${recipe.name}`, {
description: `標準產量: ${formatQuantity(yieldQty)}`
});
}; };
// 當手動切換配方時
useEffect(() => { useEffect(() => {
if (!selectedRecipeId) return; if (!selectedRecipeId) return;
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId); const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
if (targetRecipe) { if (targetRecipe) applyRecipe(targetRecipe);
applyRecipe(targetRecipe);
}
}, [selectedRecipeId]); }, [selectedRecipeId]);
// 自動產生成品批號與載入配方
useEffect(() => { useEffect(() => {
if (!data.product_id) return; if (!data.product_id) return;
// 2. 自動載入配方列表
const fetchRecipes = async () => { const fetchRecipes = async () => {
try { try {
// 改為抓取所有配方
const res = await fetch(route('api.production.recipes.by-product', data.product_id)); const res = await fetch(route('api.production.recipes.by-product', data.product_id));
const recipesData = await res.json(); const recipesData = await res.json();
if (Array.isArray(recipesData) && recipesData.length > 0) { if (Array.isArray(recipesData) && recipesData.length > 0) {
setRecipes(recipesData); setRecipes(recipesData);
// 預設選取最新的 (第一個) setSelectedRecipeId(String(recipesData[0].id));
const latest = recipesData[0];
setSelectedRecipeId(String(latest.id));
} else { } else {
// 若無配方
setRecipes([]); setRecipes([]);
setSelectedRecipeId(""); setSelectedRecipeId("");
setBomItems([]); // 清空 BOM setBomItems([]);
} }
} catch (e) { } catch (e) {
console.error("Failed to fetch recipes", e);
setRecipes([]); setRecipes([]);
setBomItems([]); setBomItems([]);
} }
@@ -330,61 +280,74 @@ export default function Create({ products, warehouses }: Props) {
fetchRecipes(); fetchRecipes();
}, [data.product_id]); }, [data.product_id]);
// 當生產數量變動時,如果是從配方載入的,則按比例更新用量 // 當有驗證錯誤時,自動聚焦到第一個錯誤欄位
useEffect(() => { useEffect(() => {
if (bomItems.length > 0 && data.output_quantity) { const errorKeys = Object.keys(errors);
// 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號 if (errorKeys.length > 0) {
// 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾 // 延遲一下確保 DOM 已更新(例如 BOM 明細行渲染)
// 但如果是剛載入inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性 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') => {
const submit = (status: 'draft' | 'completed') => { clearErrors();
// 驗證(簡單前端驗證,完整驗證在後端) let hasError = false;
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('原物料明細');
if (missingFields.length > 0) { // 草稿建立時也要求必填生產數量與預計入庫倉庫
toast.error("請填寫必要欄位", { if (!data.product_id) { setError('product_id', '請選擇成品商品'); hasError = true; }
description: `缺漏:${missingFields.join('、')}` if (!data.output_quantity) { setError('output_quantity', '請輸入生產數量'); hasError = true; }
}); if (!selectedWarehouse) { setError('warehouse_id', '請選擇預計入庫倉庫'); hasError = true; }
return; 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.map(item => ({
const formattedItems = bomItems inventory_id: parseInt(item.inventory_id),
.filter(item => status === 'draft' || (item.inventory_id && item.quantity_used)) quantity_used: parseFloat(item.quantity_used),
.map(item => ({ unit_id: item.unit_id ? parseInt(item.unit_id) : null,
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,
}));
// 使用 router.post 提交完整資料
router.post(route('production-orders.store'), { router.post(route('production-orders.store'), {
...data, ...data,
warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null, warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
items: formattedItems, items: formattedItems,
status: status, status: status,
}, { }, {
onError: (errors) => { onError: () => {
const errorCount = Object.keys(errors).length; toast.error("建立失敗,請檢查表單");
toast.error("建立失敗,請檢查表單", {
description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
});
} }
}); });
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
submit('completed'); submit('draft');
}; };
const getBomItemUnitCost = (item: BomItem) => { const getBomItemUnitCost = (item: BomItem) => {
@@ -423,7 +386,7 @@ export default function Create({ products, warehouses }: Props) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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" /> <Factory className="h-6 w-6 text-primary-main" />
</h1> </h1>
@@ -431,14 +394,18 @@ export default function Create({ products, warehouses }: Props) {
</p> </p>
</div> </div>
<Button <div className="flex items-center gap-3">
onClick={() => submit('draft')} <Button
disabled={processing} type="button"
className="gap-2 button-filled-primary" variant="default"
> onClick={() => submit('draft')}
<Save className="h-4 w-4" /> disabled={processing}
(稿) className="button-filled-primary gap-2"
</Button> >
<Save className="h-4 w-4" />
(稿)
</Button>
</div>
</div> </div>
</div> </div>
@@ -458,6 +425,7 @@ export default function Create({ products, warehouses }: Props) {
}))} }))}
placeholder="選擇成品" placeholder="選擇成品"
className="w-full h-9" 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>} {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)} onChange={(e) => setData('output_quantity', e.target.value)}
placeholder="例如: 50" placeholder="例如: 50"
className="h-9 font-mono" 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>} {errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
</div> </div>
@@ -508,6 +477,7 @@ export default function Create({ products, warehouses }: Props) {
}))} }))}
placeholder="選擇倉庫" placeholder="選擇倉庫"
className="w-full h-9" 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>} {errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
</div> </div>
@@ -600,6 +570,7 @@ export default function Create({ products, warehouses }: Props) {
options={productOptions} options={productOptions}
placeholder="選擇商品" placeholder="選擇商品"
className="w-full" className="w-full"
aria-invalid={!!errors[`items.${index}.ui_product_id` as any]}
/> />
</TableCell> </TableCell>
@@ -628,6 +599,7 @@ export default function Create({ products, warehouses }: Props) {
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"} placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
className="w-full" className="w-full"
disabled={!item.ui_warehouse_id} disabled={!item.ui_warehouse_id}
aria-invalid={!!errors[`items.${index}.inventory_id` as any]}
/> />
{item.inventory_id && (() => { {item.inventory_id && (() => {
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === 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" placeholder="0"
className="h-9 text-right" className="h-9 text-right"
disabled={!item.inventory_id} disabled={!item.inventory_id}
aria-invalid={!!errors[`items.${index}.quantity_used` as any]}
/> />
</TableCell> </TableCell>

View File

@@ -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 { Plus, Factory, Search, Eye, Pencil, Trash2 } from 'lucide-react';
import { debounce } from "lodash";
import { formatQuantity } from "@/lib/utils"; import { formatQuantity } from "@/lib/utils";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; 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"); setPerPage(filters.per_page || productionOrders.per_page?.toString() || "10");
}, [filters]); }, [filters]);
const handleFilter = () => { const debouncedFilter = useCallback(
router.get( debounce((params: any) => {
route('production-orders.index'), router.get(route("production-orders.index"), params, {
{ preserveState: true,
search, replace: true,
status: status === 'all' ? undefined : status, preserveScroll: true,
per_page: perPage, });
}, }, 300),
{ preserveState: true, replace: true, preserveScroll: true } []
); );
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 <Input
placeholder="搜尋生產單號、批號、商品名稱..." placeholder="搜尋生產單號、批號、商品名稱..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10 pr-10 h-9" className="pl-10 pr-10 h-9"
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
/> />
{search && ( {search && (
<button <button
onClick={() => { onClick={() => handleSearchChange("")}
setSearch("");
router.get(route('production-orders.index'), { ...filters, search: "" }, { preserveState: true, replace: true, preserveScroll: true });
}}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600" 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" /> <Trash2 className="h-4 w-4" />
@@ -172,15 +178,6 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto"> <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"> <Can permission="production_orders.create">
<Button <Button
onClick={handleNavigateToCreate} onClick={handleNavigateToCreate}

View File

@@ -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 { Plus, Search, Pencil, Trash2, BookOpen, Eye } from 'lucide-react';
import { debounce } from "lodash";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, Link } from "@inertiajs/react"; import { Head, router, Link } from "@inertiajs/react";
@@ -73,17 +74,25 @@ export default function RecipeIndex({ recipes, filters }: Props) {
setPerPage(filters.per_page || "10"); setPerPage(filters.per_page || "10");
}, [filters]); }, [filters]);
const handleFilter = () => { const debouncedFilter = useCallback(
router.get( debounce((params: any) => {
route('recipes.index'), router.get(route("recipes.index"), params, {
{ preserveState: true,
search, replace: true,
per_page: perPage, preserveScroll: true,
}, });
{ preserveState: true, replace: true, preserveScroll: true } }, 300),
); []
}; );
const handleSearchChange = (term: string) => {
setSearch(term);
debouncedFilter({
...filters,
search: term,
per_page: perPage,
});
};
const handlePerPageChange = (value: string) => { const handlePerPageChange = (value: string) => {
setPerPage(value); setPerPage(value);
@@ -140,16 +149,12 @@ export default function RecipeIndex({ recipes, filters }: Props) {
<Input <Input
placeholder="搜尋配方代號、名稱、產品名稱..." placeholder="搜尋配方代號、名稱、產品名稱..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10 pr-10 h-9" className="pl-10 pr-10 h-9"
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
/> />
{search && ( {search && (
<button <button
onClick={() => { onClick={() => handleSearchChange("")}
setSearch("");
router.get(route('recipes.index'), { ...filters, search: "" }, { preserveState: true, replace: true, preserveScroll: true });
}}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600" 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. */} <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 */} {/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto"> <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"> <Can permission="recipes.create">
<Link href={route('recipes.create')}> <Link href={route('recipes.create')}>
<Button className="button-filled-primary"> <Button className="button-filled-primary">

View File

@@ -56,6 +56,8 @@ interface ProductionOrder {
output_batch_number: string; output_batch_number: string;
output_box_count: string | null; output_box_count: string | null;
output_quantity: number; output_quantity: number;
actual_output_quantity: number | null;
loss_reason: string | null;
production_date: string; production_date: string;
expiry_date: string | null; expiry_date: string | null;
status: ProductionOrderStatus; status: ProductionOrderStatus;
@@ -88,12 +90,16 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
warehouseId?: number; warehouseId?: number;
batchNumber?: string; batchNumber?: string;
expiryDate?: string; expiryDate?: string;
actualOutputQuantity?: number;
lossReason?: string;
}) => { }) => {
router.patch(route('production-orders.update-status', productionOrder.id), { router.patch(route('production-orders.update-status', productionOrder.id), {
status: newStatus, status: newStatus,
warehouse_id: extraData?.warehouseId, warehouse_id: extraData?.warehouseId,
output_batch_number: extraData?.batchNumber, output_batch_number: extraData?.batchNumber,
expiry_date: extraData?.expiryDate, expiry_date: extraData?.expiryDate,
actual_output_quantity: extraData?.actualOutputQuantity,
loss_reason: extraData?.lossReason,
}, { }, {
onSuccess: () => { onSuccess: () => {
setIsWarehouseModalOpen(false); setIsWarehouseModalOpen(false);
@@ -129,6 +135,8 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
processing={processing} processing={processing}
productCode={productionOrder.product?.code} productCode={productionOrder.product?.code}
productId={productionOrder.product?.id} 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"> <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> </p>
</div> </div>
<div className="space-y-1.5"> <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"> <div className="flex items-baseline gap-1.5">
<p className="font-bold text-grey-0 text-xl"> <p className="font-bold text-grey-0 text-xl">
{formatQuantity(productionOrder.output_quantity)} {formatQuantity(productionOrder.output_quantity)}
@@ -289,6 +297,28 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
)} )}
</div> </div>
</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"> <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-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4"> <div className="flex items-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4">

View File

@@ -30,6 +30,7 @@ import {
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
import { toast } from "sonner"; import { toast } from "sonner";
import UtilityFeeDialog, { UtilityFee } from "@/Components/UtilityFee/UtilityFeeDialog"; import UtilityFeeDialog, { UtilityFee } from "@/Components/UtilityFee/UtilityFeeDialog";
import AttachmentDialog from "@/Components/UtilityFee/AttachmentDialog";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -77,8 +78,19 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isAttachmentDialogOpen, setIsAttachmentDialogOpen] = useState(false);
const [editingFee, setEditingFee] = useState<UtilityFee | null>(null); const [editingFee, setEditingFee] = useState<UtilityFee | null>(null);
const [deletingFeeId, setDeletingFeeId] = useState<number | 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>
<TableCell> <TableCell>
<div className="flex items-center justify-center gap-2"> <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"> <Can permission="utility_fees.edit">
<Button <Button
variant="outline" variant="outline"
@@ -510,6 +538,13 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
availableCategories={availableCategories} availableCategories={availableCategories}
/> />
<AttachmentDialog
open={isAttachmentDialogOpen}
onOpenChange={setIsAttachmentDialogOpen}
fee={attachmentFee}
onAttachmentsChange={handleAttachmentsChange}
/>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}> <AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>

View File

@@ -64,10 +64,6 @@ export const validateWarehouse = (formData: {
return { isValid: false, error: "倉庫名稱為必填欄位" }; return { isValid: false, error: "倉庫名稱為必填欄位" };
} }
if (!formData.address.trim()) {
return { isValid: false, error: "倉庫地址為必填欄位" };
}
return { isValid: true }; return { isValid: true };
}; };

View File

@@ -85,7 +85,7 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
| 參數名稱 | 類型 | 必填 | 說明 | | 參數名稱 | 類型 | 必填 | 說明 |
| :--- | :--- | :---: | :--- | | :--- | :--- | :---: | :--- |
| `warehouse_code` | String | **是** | 要查詢的倉庫代碼 (例如:`STORE-001`) | | `warehouse_code` | String | **是** | 要查詢的倉庫代碼 (例如:`STORE-001`,測試可使用預設建立之 `api-test-01`) |
### Response ### Response
@@ -94,7 +94,7 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
```json ```json
{ {
"status": "success", "status": "success",
"warehouse_code": "STORE-001", "warehouse_code": "api-test-01",
"data": [ "data": [
{ {
"external_pos_id": "POS-ITEM-001", "external_pos_id": "POS-ITEM-001",
@@ -136,7 +136,7 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
| 欄位名稱 | 型態 | 必填 | 說明 | | 欄位名稱 | 型態 | 必填 | 說明 |
| :--- | :--- | :---: | :--- | | :--- | :--- | :---: | :--- |
| `external_order_id` | String | **是** | 第三方系統中的唯一訂單編號,不可重複 (Unique) | | `external_order_id` | String | **是** | 第三方系統中的唯一訂單編號,不可重複 (Unique) |
| `warehouse_code` | String | **是** | 指定扣除庫存的倉庫代碼 (例如:`STORE-001`)。若找不到對應倉庫將直接拒絕請求 | | `warehouse_code` | String | **是** | 指定扣除庫存的倉庫代碼 (例如:`api-test-01` 測試倉)。若找不到對應倉庫將直接拒絕請求 |
| `payment_method` | String | 否 | 付款方式,僅接受:`cash`, `credit_card`, `line_pay`, `ecpay`, `transfer`, `other`。預設為 `cash` | | `payment_method` | String | 否 | 付款方式,僅接受:`cash`, `credit_card`, `line_pay`, `ecpay`, `transfer`, `other`。預設為 `cash` |
| `sold_at` | String(Date) | 否 | 交易發生時間,預設為當下時間 (格式: YYYY-MM-DD HH:mm:ss) | | `sold_at` | String(Date) | 否 | 交易發生時間,預設為當下時間 (格式: YYYY-MM-DD HH:mm:ss) |
| `items` | Array | **是** | 訂單明細陣列,至少需包含一筆商品 | | `items` | Array | **是** | 訂單明細陣列,至少需包含一筆商品 |
@@ -153,7 +153,7 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
```json ```json
{ {
"external_order_id": "ORD-20231026-0001", "external_order_id": "ORD-20231026-0001",
"warehouse_code": "STORE-001", "warehouse_code": "api-test-01",
"payment_method": "credit_card", "payment_method": "credit_card",
"sold_at": "2023-10-26 14:30:00", "sold_at": "2023-10-26 14:30:00",
"items": [ "items": [

View File

@@ -159,7 +159,7 @@ class PosApiTest extends TestCase
$payload = [ $payload = [
'external_order_id' => 'ORD-001', 'external_order_id' => 'ORD-001',
'warehouse_id' => $warehouseId, 'warehouse_code' => 'MAIN',
'sold_at' => now()->toIso8601String(), 'sold_at' => now()->toIso8601String(),
'items' => [ 'items' => [
[ [
@@ -175,6 +175,9 @@ class PosApiTest extends TestCase
'Accept' => 'application/json', 'Accept' => 'application/json',
])->postJson('/api/v1/integration/orders', $payload); ])->postJson('/api/v1/integration/orders', $payload);
$response->assertStatus(201)
->assertJsonPath('message', 'Order synced and stock deducted successfully');
$response->assertStatus(201) $response->assertStatus(201)
->assertJsonPath('message', 'Order synced and stock deducted successfully'); ->assertJsonPath('message', 'Order synced and stock deducted successfully');
@@ -197,6 +200,14 @@ class PosApiTest extends TestCase
'quantity' => 95, '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' => '出庫',
]);
tenancy()->end(); tenancy()->end();
} }
} }

View File

@@ -17,6 +17,7 @@ class InventoryTransferImportTest extends TestCase
use RefreshDatabase; use RefreshDatabase;
protected $user; protected $user;
protected $tenant;
protected $fromWarehouse; protected $fromWarehouse;
protected $toWarehouse; protected $toWarehouse;
protected $order; protected $order;
@@ -25,6 +26,15 @@ class InventoryTransferImportTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); 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([ $this->user = User::create([
'name' => 'Test User', 'name' => 'Test User',
'username' => 'testuser', 'username' => 'testuser',
@@ -52,10 +62,13 @@ class InventoryTransferImportTest extends TestCase
'created_by' => $this->user->id, 'created_by' => $this->user->id,
]); ]);
$category = \App\Modules\Inventory\Models\Category::create(['name' => 'General', 'code' => 'GEN']);
$this->product = Product::create([ $this->product = Product::create([
'code' => 'P001', 'code' => 'P001',
'name' => 'Test Product', 'name' => 'Test Product',
'status' => 'enabled', 'status' => 'enabled',
'category_id' => $category->id,
]); ]);
} }
@@ -80,8 +93,9 @@ class InventoryTransferImportTest extends TestCase
// 但如果我們在 Import 類別中直接讀取 $row['商品代碼'],我們得確定它真的在那裡。 // 但如果我們在 Import 類別中直接讀取 $row['商品代碼'],我們得確定它真的在那裡。
$rows = collect([ $rows = collect([
collect(['商品代碼' => 'P001', '批號' => 'BATCH001', '數量' => '10', '備註' => 'Imported Via Test']), collect(['商品代碼', '批號', '數量', '備註']),
collect(['商品代碼' => 'P001', '批號' => '', '數量' => '5', '備註' => 'Batch should be NO-BATCH']), collect(['P001', 'BATCH001', '10', 'Imported Via Test']),
collect(['P001', '', '5', 'Batch should be NO-BATCH']),
]); ]);
$import->collection($rows); $import->collection($rows);