diff --git a/.agents/rules/framework.md b/.agents/rules/framework.md index 91cfb80..4c74ce2 100644 --- a/.agents/rules/framework.md +++ b/.agents/rules/framework.md @@ -62,6 +62,10 @@ trigger: always_on * 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。 * 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。 * 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。 +* **核心要求:UI 規範與彈性設計 (重要)**: + * 在開發「新功能」或「新頁面」前,產出的 `implementation_plan.md` 中**必須包含「UI 規範核對清單」**,明確列出將使用哪些已定義於 `ui-consistency/SKILL.md` 的元件(例如:`AlertDialog`、特定圖示名稱等)。 + * **已規範部分**:絕對遵循《客戶端後台 UI 統一規範》進行實作。 + * **未規範部分**:若遇到規範外的新 UI 區塊,請保有設計彈性,運用 Tailwind 打造符合 ERP 調性的初版設計,依據使用者的實際感受進行後續調整與收錄。 ## 8. 多租戶開發規範 (Multi-tenancy Standards) 本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則: @@ -83,4 +87,14 @@ trigger: always_on ## 10. 部署與查修環境 (CI/CD & Troubleshooting) * **自動化部署**:本專案使用 CI/CD 自動化部署,開發者只需 push 程式碼至對應分支即可。 * **Demo 環境 (對應 `demo` 分支)**:若需查修測試站問題(例如查看 Error Log 或資料庫),請連線 `ssh gitea_work`。 -* **Production 環境 (對應 `main` 分支)**:若需查修正式站問題,請連線 `ssh erp`。 \ No newline at end of file +* **Production 環境 (對應 `main` 分支)**:若需查修正式站問題,請連線 `ssh erp`。 + +## 11. 瀏覽器測試規範 (Browser Testing) +當需要進行瀏覽器自動化測試或手動驗證時,請遵守以下連線資訊: + +* **本地測試網址**:`http://localhost:8081/` +* **預設管理員帳號**:`admin` +* **預設管理員密碼**:`password` + +> [!IMPORTANT] +> 在執行 browser subagent 或進行 E2E 測試時,請務必確認為 `8081` Port,以避免連線至錯誤的服務環境。 \ No newline at end of file diff --git a/.agents/rules/skill-trigger.md b/.agents/rules/skill-trigger.md index a78b82c..2b53677 100644 --- a/.agents/rules/skill-trigger.md +++ b/.agents/rules/skill-trigger.md @@ -19,6 +19,8 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。 | 跨模組、Service Interface、`Contracts`、模組間通訊、`ServiceProvider` 綁定、禁止跨模組引用 | **跨模組調用與通訊規範** | `.agents/skills/cross-module-communication/SKILL.md` | | 按鈕樣式、表格規範、圖標、分頁、Badge、Toast、表單、UI 統一、頁面佈局、`button-filled-*`、`button-outlined-*`、`lucide-react`、色彩系統 | **客戶端後台 UI 統一規範** | `.agents/skills/ui-consistency/SKILL.md` | | Git 分支、commit、push、合併、部署、`feature/`、`hotfix/`、`develop`、`main` | **Git 分支管理與開發規範** | `.agents/skills/git-workflows/SKILL.md` | +| E2E、端到端測試、Playwright、`spec.ts`、功能驗證、自動化測試、回歸測試 | **E2E 端到端測試規範** | `.agents/skills/e2e-testing/SKILL.md` | +| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` | --- @@ -31,6 +33,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。 1. **permission-management** — 設定權限 2. **ui-consistency** — 遵循 UI 規範 3. **activity-logging** — 若涉及 Model CRUD,需加上操作紀錄 +4. **e2e-testing** — 確認是否需要新增對應的 E2E 測試 ### 🔴 新增或修改 Model 時 必須讀取: @@ -41,6 +44,10 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。 必須讀取: 1. **git-workflows** — 分支命名與 commit 格式 +### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時 +必須讀取: +1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範 + --- ## 注意事項 diff --git a/.agents/skills/e2e-testing/SKILL.md b/.agents/skills/e2e-testing/SKILL.md new file mode 100644 index 0000000..c4faa2c --- /dev/null +++ b/.agents/skills/e2e-testing/SKILL.md @@ -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`? diff --git a/.agents/skills/git-workflows/SKILL.md b/.agents/skills/git-workflows/SKILL.md index 388a709..97b9c7d 100644 --- a/.agents/skills/git-workflows/SKILL.md +++ b/.agents/skills/git-workflows/SKILL.md @@ -18,11 +18,12 @@ description: 規範開發過程中的 Git 分支架構、合併限制、環境 ## 2. 發布時段與約束 (Release Window) ### Main 分支發布限制 (Mandatory) -1. **標準發布時間**:週一至週四,**12:00 (中午) 之前**。 -2. **非標準時段提醒**:若於上述時段以外(週五、週末、國定假日或下班時間)欲合併至 `main`: +1. **強制規範**:若執行推送/合併指令時未明確包含目標分支,**嚴禁** 自行預設或推論為 `main`。我必須先詢問使用者:「請問要推送到哪一個目標分支?(dev / demo / main)」。 +2. **標準發布時間**:週一至週四,**12:00 (中午) 之前**。 +3. **非標準時段提醒**:若於上述時段以外(週五、週末、國定假日或下班時間)欲合併至 `main`: - AI 助手**必須攔截並主動提示風險**(例如:週末災難風險)。 - 必須取得使用者明確書面同意(如:「我確定現在要上線」)方可執行。 -3. **合併鏈路**:一般功能/修正必須先上 `demo` 測試。`main` 的程式原則上應從 `demo` 分支合併而來。 +4. **合併鏈路**:一般功能/修正必須先上 `demo` 測試。`main` 的程式原則上應從 `demo` 分支合併而來。 ## 3. 開發與修復流程 (SOP) diff --git a/.agents/skills/ui-consistency/SKILL.md b/.agents/skills/ui-consistency/SKILL.md index 1df0d23..cea8f8e 100644 --- a/.agents/skills/ui-consistency/SKILL.md +++ b/.agents/skills/ui-consistency/SKILL.md @@ -781,7 +781,38 @@ import { SearchableSelect } from "@/Components/ui/searchable-select"; - **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9` - **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。 -## 11.6 日期顯示規範 (Date Display) +## 11.6 數字輸入框規範 (Numeric Inputs) + +當需求為輸入**整數**數量(例如:實際產出數量、標準產出量)時,**嚴禁自行開發組合 `Plus` (+) 與 `Minus` (-) 按鈕的複合元件**。 + +**必須使用原生 HTML5 數字輸入與屬性**: +1. 使用 `` 確保預設渲染瀏覽器原生的上下調整小箭頭 (Spinner)。 +2. 針對整數需求,固定加上 `step="1"` 屬性。 +3. 視需求加上 `min` 與 `max` 控制上下限。 + +這樣既能保持與現有「新增配方」等模組的「標準產出量」欄位行為高度一致,亦能維持畫面的極簡風格。 + +```tsx +// ✅ 正確:依賴原生行為 + setActualOutputQuantity(e.target.value)} + className="h-9 w-24 text-center" +/> + +// ❌ 錯誤:過度設計、浪費空間與破壞一致性 +
+ + + +
+``` + +## 11.7 日期顯示規範 (Date Display) 前端顯示日期時**禁止直接顯示原始 ISO 字串**(如 `2024-03-06T08:30:00.000000Z`),必須使用 `resources/js/lib/date.ts` 提供的工具函式。 diff --git a/.agents/workflows/now-push.md b/.agents/workflows/now-push.md index 3fe9709..3b36c3f 100644 --- a/.agents/workflows/now-push.md +++ b/.agents/workflows/now-push.md @@ -8,28 +8,20 @@ description: 將目前的變更提交並推送至指定的遠端分支 (遵守 ## 執行步驟 -1. **檢查變更內容** - 執行 `git status` 與 `git diff` 檢查目前的工作目錄,確保提交內容正確。 +1. **讀取規範 (Mandatory)** + 在執行任何 Git 操作前,**必須** 先讀取 Git 分支管理與開發規範: + `view_file` -> [Git SKILL.md](file:///home/mama/projects/star-erp/.agents/skills/git-workflows/SKILL.md) -2. **撰寫規格化提交訊息 (Commit Message)** - - 訊息一律使用 **繁體中文 (台灣用語)**。 - - 必須使用以下前綴之一: - - `[FIX]`:修復 Bug。 - - `[FEAT]`:新增功能。 - - `[DOCS]`:文件更新。 - - `[STYLE]`:UI/樣式/格式調整。 - - `[REFACTOR]`:程式碼重構。 - - 描述應具體且真實反映修改內容。 +2. **檢查與準備** + - 執行 `git status` 檢查目前工作目錄。 + - 根據 **SKILL.md** 的規範撰寫繁體中文提交訊息。 -3. **目標分支安全檢查 (Release Window & Source Check)** - - 若使用者指定的目標分支包含 **`main`**: - - **來源檢查**:根據規範,上線 `main` 前必須先確保程式碼已在 `demo` 分支驗證完畢。我會優先檢查 `demo` 與 `main` 的差異,並提醒使用者應從 `demo` 合併。 - - **檢查目前時間**:標準發布時段為 **週一至週四 12:00 (中午) 之前**。 - - 若在非標準時段(週五、週末、下班時間),**必須** 先攔截並主動提醒風險,取得使用者明確書面同意(例如:「我確定現在要上線」)後方才執行推送。 +3. **目標分支安全檢查** + - 嚴格遵守 **SKILL.md** 中的「分支架構」、「發布時段」與「強制分支明確指定」規則。 + - 若未指明目標分支,主動詢問使用者,不可私自預設為 `main`。 4. **執行推送 (Push)** - - 依據指令帶入的分支名稱執行推送。 - - 範例:`git push origin [目前分支]:[目標分支]`。 + - 通過安全檢查後,執行:`git push origin [目前分支]:[目標分支]`。 -5. **同步關聯分支** - - 若為 `main` 的 Hotfix,修復後應評估是否同步回 `demo` 或 `dev` 分支。 \ No newline at end of file +5. **後續同步** + - 依照 **SKILL.md** 的「緊急修復流程(Hotfix)」評估是否需要同步回 `demo` 或 `dev` 分支。 \ No newline at end of file diff --git a/.gitignore b/.gitignore index f99723e..975985c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ /public/storage /storage/*.key /storage/pail +/storage/tenant* /vendor Homestead.json Homestead.yaml @@ -33,3 +34,12 @@ docs/f6_1770350984272.xlsx .gitignore BOM表自動計算成本.md 公共事業費-類別維護.md + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ +e2e/screenshots/ diff --git a/README.md b/README.md index 5e62d90..792cbf1 100644 --- a/README.md +++ b/README.md @@ -180,4 +180,3 @@ docker compose down - **多租戶**: - 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。 - 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。 - diff --git a/app/Modules/Core/Controllers/RoleController.php b/app/Modules/Core/Controllers/RoleController.php index 7d85be4..055630e 100644 --- a/app/Modules/Core/Controllers/RoleController.php +++ b/app/Modules/Core/Controllers/RoleController.php @@ -151,7 +151,7 @@ class RoleController extends Controller */ private function getGroupedPermissions() { - $allPermissions = Permission::orderBy('name')->get(); + $allPermissions = Permission::select('id', 'name', 'guard_name')->orderBy('name')->get(); $grouped = []; foreach ($allPermissions as $permission) { diff --git a/app/Modules/Core/Services/CoreService.php b/app/Modules/Core/Services/CoreService.php index 7eaa72f..61ffcaf 100644 --- a/app/Modules/Core/Services/CoreService.php +++ b/app/Modules/Core/Services/CoreService.php @@ -16,7 +16,7 @@ class CoreService implements CoreServiceInterface */ public function getUsersByIds(array $ids): Collection { - return User::whereIn('id', $ids)->get(); + return User::select('id', 'name')->whereIn('id', $ids)->get(); } /** @@ -37,7 +37,7 @@ class CoreService implements CoreServiceInterface */ public function getAllUsers(): Collection { - return User::all(); + return User::select('id', 'name')->get(); } public function ensureSystemUserExists() diff --git a/app/Modules/Finance/Controllers/AccountingReportController.php b/app/Modules/Finance/Controllers/AccountingReportController.php index 75452e5..cf202cb 100644 --- a/app/Modules/Finance/Controllers/AccountingReportController.php +++ b/app/Modules/Finance/Controllers/AccountingReportController.php @@ -69,14 +69,25 @@ class AccountingReportController extends Controller } $exportData = $allRecords->map(function ($record) { + $taxAmount = (float)($record['tax_amount'] ?? 0); + $totalAmount = (float)($record['amount'] ?? 0); + $untaxedAmount = $totalAmount - $taxAmount; + return [ $record['date'], $record['source'], $record['category'], $record['item'], $record['reference'], - $record['invoice_number'], - $record['amount'], + $record['invoice_date'] ?? '-', + $record['invoice_number'] ?? '-', + $untaxedAmount, + $taxAmount, + $totalAmount, + $record['payment_method'] ?? '-', + $record['payment_note'] ?? '-', + $record['remarks'] ?? '-', + $record['status'] ?? '-', ]; }); @@ -91,7 +102,11 @@ class AccountingReportController extends Controller // BOM for Excel compatibility with UTF-8 fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF)); - fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']); + fputcsv($file, [ + '日期', '來源', '類別', '項目', '參考單號', + '發票日期', '發票號碼', '未稅金額', '稅額', '總金額', + '付款方式', '付款備註', '內部備註', '狀態' + ]); foreach ($exportData as $row) { fputcsv($file, $row); diff --git a/app/Modules/Finance/Controllers/UtilityFeeController.php b/app/Modules/Finance/Controllers/UtilityFeeController.php index 9cc7738..b8c00fe 100644 --- a/app/Modules/Finance/Controllers/UtilityFeeController.php +++ b/app/Modules/Finance/Controllers/UtilityFeeController.php @@ -4,8 +4,10 @@ namespace App\Modules\Finance\Controllers; use App\Http\Controllers\Controller; use App\Modules\Finance\Models\UtilityFee; +use App\Modules\Finance\Models\UtilityFeeAttachment; use App\Modules\Finance\Contracts\FinanceServiceInterface; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; use Inertia\Inertia; class UtilityFeeController extends Controller @@ -103,8 +105,82 @@ class UtilityFeeController extends Controller ->event('deleted') ->log('deleted'); + // 刪除實體檔案 (如果 cascade 沒處理或是想要手動清理) + foreach ($utility_fee->attachments as $attachment) { + Storage::disk('public')->delete($attachment->file_path); + } + $utility_fee->delete(); return redirect()->back(); } + + /** + * 獲取附件列表 + */ + public function attachments(UtilityFee $utility_fee) + { + return response()->json([ + 'attachments' => $utility_fee->attachments()->orderBy('created_at', 'desc')->get() + ]); + } + + /** + * 上傳附件 + */ + public function uploadAttachment(Request $request, UtilityFee $utility_fee) + { + $request->validate([ + 'file' => 'required|file|mimes:jpeg,jpg,png,webp,pdf|max:2048', // 2MB + ]); + + // 檢查數量限制 (最多 3 張) + if ($utility_fee->attachments()->count() >= 3) { + return response()->json(['message' => '附件數量已達上限 (最多 3 個)'], 422); + } + + $file = $request->file('file'); + $path = $file->store("utility-fee-attachments/{$utility_fee->id}", 'public'); + + $attachment = $utility_fee->attachments()->create([ + 'file_path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $file->getMimeType(), + 'size' => $file->getSize(), + ]); + + activity() + ->performedOn($utility_fee) + ->causedBy(auth()->user()) + ->event('attachment_uploaded') + ->log("uploaded attachment: {$attachment->original_name}"); + + return response()->json([ + 'message' => '上傳成功', + 'attachment' => $attachment + ]); + } + + /** + * 刪除附件 + */ + public function deleteAttachment(UtilityFee $utility_fee, UtilityFeeAttachment $attachment) + { + // 確保附件屬於該費用 + if ($attachment->utility_fee_id !== $utility_fee->id) { + abort(403); + } + + Storage::disk('public')->delete($attachment->file_path); + + $attachment->delete(); + + activity() + ->performedOn($utility_fee) + ->causedBy(auth()->user()) + ->event('attachment_deleted') + ->log("deleted attachment: {$attachment->original_name}"); + + return response()->json(['message' => '刪除成功']); + } } diff --git a/app/Modules/Finance/Models/UtilityFee.php b/app/Modules/Finance/Models/UtilityFee.php index aba6528..d362b50 100644 --- a/app/Modules/Finance/Models/UtilityFee.php +++ b/app/Modules/Finance/Models/UtilityFee.php @@ -7,9 +7,16 @@ use Illuminate\Database\Eloquent\Model; class UtilityFee extends Model { - /** @use HasFactory<\Database\Factories\UtilityFeeFactory> */ use HasFactory; + /** + * 此公共事業費的附件 + */ + public function attachments() + { + return $this->hasMany(UtilityFeeAttachment::class); + } + // 狀態常數 const STATUS_PENDING = 'pending'; const STATUS_PAID = 'paid'; diff --git a/app/Modules/Finance/Models/UtilityFeeAttachment.php b/app/Modules/Finance/Models/UtilityFeeAttachment.php new file mode 100644 index 0000000..f9b0575 --- /dev/null +++ b/app/Modules/Finance/Models/UtilityFeeAttachment.php @@ -0,0 +1,36 @@ +belongsTo(UtilityFee::class); + } + + /** + * 獲取附件的全路徑 URL + */ + public function getUrlAttribute() + { + return tenant_asset($this->file_path); + } +} diff --git a/app/Modules/Finance/Routes/web.php b/app/Modules/Finance/Routes/web.php index 1ec273d..01aae1f 100644 --- a/app/Modules/Finance/Routes/web.php +++ b/app/Modules/Finance/Routes/web.php @@ -30,6 +30,11 @@ Route::middleware('auth')->group(function () { }); Route::middleware('permission:utility_fees.edit')->group(function () { Route::put('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'update'])->name('utility-fees.update'); + + // 附件管理 (Ajax) + Route::get('/utility-fees/{utility_fee}/attachments', [UtilityFeeController::class, 'attachments'])->name('utility-fees.attachments'); + Route::post('/utility-fees/{utility_fee}/attachments', [UtilityFeeController::class, 'uploadAttachment'])->name('utility-fees.upload-attachment'); + Route::delete('/utility-fees/{utility_fee}/attachments/{attachment}', [UtilityFeeController::class, 'deleteAttachment'])->name('utility-fees.delete-attachment'); }); Route::middleware('permission:utility_fees.delete')->group(function () { Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy'); diff --git a/app/Modules/Finance/Services/AccountPayableService.php b/app/Modules/Finance/Services/AccountPayableService.php index 6ec6eb2..b3a69bd 100644 --- a/app/Modules/Finance/Services/AccountPayableService.php +++ b/app/Modules/Finance/Services/AccountPayableService.php @@ -70,6 +70,7 @@ class AccountPayableService $latest = AccountPayable::where('document_number', 'like', $lastPrefix) ->orderBy('document_number', 'desc') + ->lockForUpdate() ->first(); if (!$latest) { diff --git a/app/Modules/Finance/Services/FinanceService.php b/app/Modules/Finance/Services/FinanceService.php index 45ab42d..4e5eadb 100644 --- a/app/Modules/Finance/Services/FinanceService.php +++ b/app/Modules/Finance/Services/FinanceService.php @@ -19,23 +19,48 @@ class FinanceService implements FinanceServiceInterface public function getAccountingReportData(string $start, string $end): array { - // 1. 獲取採購單資料 - $purchaseOrders = $this->procurementService->getPurchaseOrdersByDate($start, $end) - ->map(function ($po) { - return [ - 'id' => 'PO-' . $po->id, - 'date' => Carbon::parse($po->created_at)->timezone(config('app.timezone'))->toDateString(), - 'source' => '採購單', - 'category' => '進貨支出', - 'item' => $po->vendor->name ?? '未知廠商', - 'reference' => $po->code, - 'invoice_number' => $po->invoice_number, - 'amount' => (float)$po->grand_total, - ]; - }); + // 1. 獲取應付帳款資料 (已付款) + $accountPayables = \App\Modules\Finance\Models\AccountPayable::where('status', \App\Modules\Finance\Models\AccountPayable::STATUS_PAID) + ->whereNotNull('paid_at') + ->whereBetween('paid_at', [$start, $end]) + ->get(); - // 2. 獲取公共事業費 (注意:目前資料表欄位為 transaction_date) - $utilityFees = UtilityFee::whereBetween('transaction_date', [$start, $end]) + // 取得供應商資料 (Manual Hydration) + $vendorIds = $accountPayables->pluck('vendor_id')->unique()->filter()->toArray(); + $vendorsMap = $this->procurementService->getVendorsByIds($vendorIds)->keyBy('id'); + + // 付款方式對映 + $paymentMethodMap = [ + 'cash' => '現金', + 'bank_transfer' => '銀行轉帳', + 'check' => '支票', + 'credit_card' => '信用卡', + ]; + + $payableRecords = $accountPayables->map(function ($ap) use ($vendorsMap, $paymentMethodMap) { + $vendorName = isset($vendorsMap[$ap->vendor_id]) ? $vendorsMap[$ap->vendor_id]->name : '未知廠商'; + $mappedPaymentMethod = $paymentMethodMap[$ap->payment_method] ?? $ap->payment_method; + return [ + 'id' => 'AP-' . $ap->id, + 'date' => Carbon::parse($ap->paid_at)->timezone(config('app.timezone'))->toDateString(), + 'source' => '應付帳款', + 'category' => '進貨支出', + 'item' => $vendorName, + 'reference' => $ap->document_number, + 'invoice_date' => $ap->invoice_date ? $ap->invoice_date->format('Y-m-d') : null, + 'invoice_number' => $ap->invoice_number, + 'amount' => (float)$ap->total_amount, + 'tax_amount' => (float)$ap->tax_amount, + 'status' => $ap->status, + 'payment_method' => $mappedPaymentMethod, + 'payment_note' => $ap->payment_note, + 'remarks' => $ap->remarks, + ]; + }); + + // 2. 獲取公共事業費 (已繳費) + $utilityFees = UtilityFee::where('payment_status', UtilityFee::STATUS_PAID) + ->whereBetween('transaction_date', [$start, $end]) ->get() ->map(function ($fee) { return [ @@ -45,12 +70,18 @@ class FinanceService implements FinanceServiceInterface 'category' => $fee->category, 'item' => $fee->description ?: $fee->category, 'reference' => '-', + 'invoice_date' => null, 'invoice_number' => $fee->invoice_number, 'amount' => (float)$fee->amount, + 'tax_amount' => 0.0, + 'status' => $fee->payment_status, + 'payment_method' => null, + 'payment_note' => null, + 'remarks' => $fee->description, ]; }); - $allRecords = $purchaseOrders->concat($utilityFees) + $allRecords = $payableRecords->concat($utilityFees) ->sortByDesc('date') ->values(); @@ -58,7 +89,7 @@ class FinanceService implements FinanceServiceInterface 'records' => $allRecords, 'summary' => [ 'total_amount' => $allRecords->sum('amount'), - 'purchase_total' => $purchaseOrders->sum('amount'), + 'payable_total' => $payableRecords->sum('amount'), 'utility_total' => $utilityFees->sum('amount'), 'record_count' => $allRecords->count(), ] @@ -67,7 +98,7 @@ class FinanceService implements FinanceServiceInterface public function getUtilityFees(array $filters) { - $query = UtilityFee::query(); + $query = UtilityFee::withCount('attachments'); if (!empty($filters['search'])) { $search = $filters['search']; diff --git a/app/Modules/Integration/Actions/SyncOrderAction.php b/app/Modules/Integration/Actions/SyncOrderAction.php index a9b61c1..107e5fc 100644 --- a/app/Modules/Integration/Actions/SyncOrderAction.php +++ b/app/Modules/Integration/Actions/SyncOrderAction.php @@ -133,7 +133,10 @@ class SyncOrderAction $warehouseId, $qty, "POS Order: " . $order->external_order_id, - true + true, + null, + \App\Modules\Integration\Models\SalesOrder::class, + $order->id ); } diff --git a/app/Modules/Integration/Actions/SyncVendingOrderAction.php b/app/Modules/Integration/Actions/SyncVendingOrderAction.php index 7ec2061..06e153b 100644 --- a/app/Modules/Integration/Actions/SyncVendingOrderAction.php +++ b/app/Modules/Integration/Actions/SyncVendingOrderAction.php @@ -130,7 +130,10 @@ class SyncVendingOrderAction $warehouseId, $qty, "Vending Order: " . $order->external_order_id, - true + true, + null, + \App\Modules\Integration\Models\SalesOrder::class, + $order->id ); } diff --git a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php index 9f20b39..8e3af22 100644 --- a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php +++ b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php @@ -23,7 +23,7 @@ interface InventoryServiceInterface * @param string|null $slot * @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. diff --git a/app/Modules/Inventory/Controllers/AdjustDocController.php b/app/Modules/Inventory/Controllers/AdjustDocController.php index 51926ca..964e148 100644 --- a/app/Modules/Inventory/Controllers/AdjustDocController.php +++ b/app/Modules/Inventory/Controllers/AdjustDocController.php @@ -63,7 +63,7 @@ class AdjustDocController extends Controller return Inertia::render('Inventory/Adjust/Index', [ 'docs' => $docs, - 'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]), + 'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]), 'filters' => $request->only(['warehouse_id', 'search', 'per_page']), ]); } diff --git a/app/Modules/Inventory/Controllers/CountDocController.php b/app/Modules/Inventory/Controllers/CountDocController.php index 16c555d..af85dcb 100644 --- a/app/Modules/Inventory/Controllers/CountDocController.php +++ b/app/Modules/Inventory/Controllers/CountDocController.php @@ -67,7 +67,7 @@ class CountDocController extends Controller return Inertia::render('Inventory/Count/Index', [ 'docs' => $docs, - 'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]), + 'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]), 'filters' => $request->only(['warehouse_id', 'search', 'per_page']), ]); } diff --git a/app/Modules/Inventory/Controllers/InventoryController.php b/app/Modules/Inventory/Controllers/InventoryController.php index 6c5f157..2a0c5d5 100644 --- a/app/Modules/Inventory/Controllers/InventoryController.php +++ b/app/Modules/Inventory/Controllers/InventoryController.php @@ -41,7 +41,7 @@ class InventoryController extends Controller 'inventories.lastIncomingTransaction', 'inventories.lastOutgoingTransaction' ]); - $allProducts = Product::with('category')->get(); + $allProducts = Product::select('id', 'name', 'code', 'category_id')->with('category:id,name')->get(); // 1. 準備 availableProducts $availableProducts = $allProducts->map(function ($product) { @@ -167,8 +167,8 @@ class InventoryController extends Controller public function create(Warehouse $warehouse) { // ... (unchanged) ... - $products = Product::with(['baseUnit', 'largeUnit']) - ->select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price') + $products = Product::select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price') + ->with(['baseUnit:id,name', 'largeUnit:id,name']) ->get() ->map(function ($product) { return [ diff --git a/app/Modules/Inventory/Controllers/ProductController.php b/app/Modules/Inventory/Controllers/ProductController.php index b4c7d8d..565ab6d 100644 --- a/app/Modules/Inventory/Controllers/ProductController.php +++ b/app/Modules/Inventory/Controllers/ProductController.php @@ -112,12 +112,12 @@ class ProductController extends Controller ]; }); - $categories = Category::where('is_active', true)->get(); + $categories = Category::select('id', 'name')->where('is_active', true)->get(); return Inertia::render('Product/Index', [ 'products' => $products, - 'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]), - 'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]), + 'categories' => $categories->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]), + 'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]), 'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']), ]); } @@ -172,8 +172,8 @@ class ProductController extends Controller public function create(): Response { return Inertia::render('Product/Create', [ - 'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]), - 'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]), + 'categories' => Category::select('id', 'name')->where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]), + 'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]), ]); } @@ -231,8 +231,8 @@ class ProductController extends Controller 'wholesale_price' => (float) $product->wholesale_price, 'is_active' => (bool) $product->is_active, ], - 'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]), - 'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]), + 'categories' => Category::select('id', 'name')->where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]), + 'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]), ]); } diff --git a/app/Modules/Inventory/Controllers/SafetyStockController.php b/app/Modules/Inventory/Controllers/SafetyStockController.php index 462f5a5..90ec7b4 100644 --- a/app/Modules/Inventory/Controllers/SafetyStockController.php +++ b/app/Modules/Inventory/Controllers/SafetyStockController.php @@ -19,7 +19,7 @@ class SafetyStockController extends Controller */ public function index(Warehouse $warehouse) { - $allProducts = Product::with(['category', 'baseUnit'])->get(); + $allProducts = Product::select('id', 'name', 'category_id', 'base_unit_id')->with(['category:id,name', 'baseUnit:id,name'])->get(); // 準備可選商品列表 $availableProducts = $allProducts->map(function ($product) { diff --git a/app/Modules/Inventory/Controllers/TransferOrderController.php b/app/Modules/Inventory/Controllers/TransferOrderController.php index c67afa3..ab612a2 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -65,7 +65,7 @@ class TransferOrderController extends Controller return Inertia::render('Inventory/Transfer/Index', [ 'orders' => $orders, - 'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]), + 'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]), 'filters' => $request->only(['search', 'warehouse_id', 'per_page']), ]); } diff --git a/app/Modules/Inventory/Services/AdjustService.php b/app/Modules/Inventory/Services/AdjustService.php index 47bcfbc..c1369c3 100644 --- a/app/Modules/Inventory/Services/AdjustService.php +++ b/app/Modules/Inventory/Services/AdjustService.php @@ -44,16 +44,23 @@ class AdjustService ); // 2. 抓取有差異的明細 (diff_qty != 0) + $itemsToInsert = []; foreach ($countDoc->items as $item) { if (abs($item->diff_qty) < 0.0001) continue; - $adjDoc->items()->create([ + $itemsToInsert[] = [ + 'inventory_adjust_doc_id' => $adjDoc->id, 'product_id' => $item->product_id, 'batch_number' => $item->batch_number, 'qty_before' => $item->system_qty, 'adjust_qty' => $item->diff_qty, 'notes' => "盤點差異: " . $item->diff_qty, - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + if (!empty($itemsToInsert)) { + InventoryAdjustItem::insert($itemsToInsert); } return $adjDoc; @@ -84,25 +91,35 @@ class AdjustService $doc->items()->delete(); + $itemsToInsert = []; + $productIds = collect($itemsData)->pluck('product_id')->unique()->toArray(); + $products = \App\Modules\Inventory\Models\Product::whereIn('id', $productIds)->get()->keyBy('id'); + + // 批次取得當前庫存 + $inventories = Inventory::where('warehouse_id', $doc->warehouse_id) + ->whereIn('product_id', $productIds) + ->get(); + foreach ($itemsData as $data) { - // 取得當前庫存作為 qty_before 參考 (僅參考,實際扣減以過帳當下為準) - $inventory = Inventory::where('warehouse_id', $doc->warehouse_id) - ->where('product_id', $data['product_id']) + $inventory = $inventories->where('product_id', $data['product_id']) ->where('batch_number', $data['batch_number'] ?? null) ->first(); $qtyBefore = $inventory ? $inventory->quantity : 0; - $newItem = $doc->items()->create([ + $itemsToInsert[] = [ + 'inventory_adjust_doc_id' => $doc->id, 'product_id' => $data['product_id'], 'batch_number' => $data['batch_number'] ?? null, 'qty_before' => $qtyBefore, 'adjust_qty' => $data['adjust_qty'], 'notes' => $data['notes'] ?? null, - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; // 更新日誌中的品項列表 - $productName = \App\Modules\Inventory\Models\Product::find($data['product_id'])?->name; + $productName = $products->get($data['product_id'])?->name ?? '未知商品'; $found = false; foreach ($updatedItems as $idx => $ui) { if ($ui['product_name'] === $productName && $ui['new'] === null) { @@ -126,6 +143,10 @@ class AdjustService } } + if (!empty($itemsToInsert)) { + InventoryAdjustItem::insert($itemsToInsert); + } + // 清理沒被更新到的舊品項 (即真正被刪除的) $finalUpdatedItems = []; foreach ($updatedItems as $ui) { @@ -162,11 +183,20 @@ class AdjustService foreach ($doc->items as $item) { if ($item->adjust_qty == 0) continue; - $inventory = Inventory::firstOrNew([ + // 補上 lockForUpdate() 防止併發衝突 + $inventory = Inventory::where([ 'warehouse_id' => $doc->warehouse_id, 'product_id' => $item->product_id, 'batch_number' => $item->batch_number, - ]); + ])->lockForUpdate()->first(); + + if (!$inventory) { + $inventory = new Inventory([ + 'warehouse_id' => $doc->warehouse_id, + 'product_id' => $item->product_id, + 'batch_number' => $item->batch_number, + ]); + } // 如果是新建立的 object (id 為空),需要初始化 default 並先行儲存 if (!$inventory->exists) { diff --git a/app/Modules/Inventory/Services/GoodsReceiptService.php b/app/Modules/Inventory/Services/GoodsReceiptService.php index 79e16a4..8560be5 100644 --- a/app/Modules/Inventory/Services/GoodsReceiptService.php +++ b/app/Modules/Inventory/Services/GoodsReceiptService.php @@ -47,14 +47,15 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei $productIds = collect($data['items'])->pluck('product_id')->unique()->toArray(); $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + $itemsToInsert = []; foreach ($data['items'] as $itemData) { // 非標準類型:使用手動輸入的小計;標準類型:自動計算 $totalAmount = !empty($itemData['subtotal']) && $data['type'] !== 'standard' ? (float) $itemData['subtotal'] : $itemData['quantity_received'] * $itemData['unit_price']; - // Create GR Item - $grItem = new GoodsReceiptItem([ + $itemsToInsert[] = [ + 'goods_receipt_id' => $goodsReceipt->id, 'product_id' => $itemData['product_id'], 'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null, 'quantity_received' => $itemData['quantity_received'], @@ -62,8 +63,9 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei 'total_amount' => $totalAmount, 'batch_number' => $itemData['batch_number'] ?? null, 'expiry_date' => $itemData['expiry_date'] ?? null, - ]); - $goodsReceipt->items()->save($grItem); + 'created_at' => now(), + 'updated_at' => now(), + ]; $product = $products->get($itemData['product_id']); $diff['added'][] = [ @@ -76,6 +78,10 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei ]; } + if (!empty($itemsToInsert)) { + GoodsReceiptItem::insert($itemsToInsert); + } + // 4. 手動發送高品質日誌(包含品項明細) activity() ->performedOn($goodsReceipt) @@ -146,13 +152,15 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei if (isset($data['items'])) { $goodsReceipt->items()->delete(); + $itemsToInsert = []; foreach ($data['items'] as $itemData) { // 非標準類型:使用手動輸入的小計;標準類型:自動計算 $totalAmount = !empty($itemData['subtotal']) && $goodsReceipt->type !== 'standard' ? (float) $itemData['subtotal'] : $itemData['quantity_received'] * $itemData['unit_price']; - $grItem = new GoodsReceiptItem([ + $itemsToInsert[] = [ + 'goods_receipt_id' => $goodsReceipt->id, 'product_id' => $itemData['product_id'], 'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null, 'quantity_received' => $itemData['quantity_received'], @@ -160,8 +168,13 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei 'total_amount' => $totalAmount, 'batch_number' => $itemData['batch_number'] ?? null, 'expiry_date' => $itemData['expiry_date'] ?? null, - ]); - $goodsReceipt->items()->save($grItem); + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + if (!empty($itemsToInsert)) { + GoodsReceiptItem::insert($itemsToInsert); } } @@ -248,11 +261,14 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei */ public function submit(GoodsReceipt $goodsReceipt) { - if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) { - throw new \Exception('只有草稿或被退回的進貨單可以確認點收。'); - } - return DB::transaction(function () use ($goodsReceipt) { + // Pessimistic locking to prevent double submission + $goodsReceipt = GoodsReceipt::lockForUpdate()->find($goodsReceipt->id); + + if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) { + throw new \Exception('只有草稿或被退回的進貨單可以確認點收。'); + } + $goodsReceipt->status = GoodsReceipt::STATUS_COMPLETED; $goodsReceipt->save(); diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php index 316fb8c..2993fb6 100644 --- a/app/Modules/Inventory/Services/InventoryService.php +++ b/app/Modules/Inventory/Services/InventoryService.php @@ -13,7 +13,7 @@ class InventoryService implements InventoryServiceInterface { public function getAllWarehouses() { - return Warehouse::all(); + return Warehouse::select('id', 'name', 'code', 'type')->get(); } public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection @@ -38,12 +38,14 @@ class InventoryService implements InventoryServiceInterface public function getAllProducts() { - return Product::with(['baseUnit', 'largeUnit'])->get(); + return Product::select('id', 'name', 'code', 'base_unit_id', 'large_unit_id') + ->with(['baseUnit:id,name', 'largeUnit:id,name']) + ->get(); } public function getUnits() { - return \App\Modules\Inventory\Models\Unit::all(); + return \App\Modules\Inventory\Models\Unit::select('id', 'name')->get(); } public function getInventoriesByIds(array $ids, array $with = []) @@ -85,9 +87,9 @@ class InventoryService implements InventoryServiceInterface return $stock >= $quantity; } - public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): void + public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null): 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) ->where('warehouse_id', $warehouseId) ->where('quantity', '>', 0); @@ -96,7 +98,8 @@ class InventoryService implements InventoryServiceInterface $query->where('location', $slot); } - $inventories = $query->orderBy('arrival_date', 'asc') + $inventories = $query->lockForUpdate() + ->orderBy('arrival_date', 'asc') ->get(); $remainingToDecrease = $quantity; @@ -105,7 +108,7 @@ class InventoryService implements InventoryServiceInterface if ($remainingToDecrease <= 0) break; $decreaseAmount = min($inventory->quantity, $remainingToDecrease); - $this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason); + $this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason, $referenceType, $referenceId); $remainingToDecrease -= $decreaseAmount; } @@ -136,7 +139,7 @@ class InventoryService implements InventoryServiceInterface ]); } - $this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason); + $this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason, $referenceType, $referenceId); } else { throw new \Exception("庫存不足,無法扣除所有請求的數量。"); } diff --git a/app/Modules/Inventory/Services/StoreRequisitionService.php b/app/Modules/Inventory/Services/StoreRequisitionService.php index 0cf0e80..4b9b093 100644 --- a/app/Modules/Inventory/Services/StoreRequisitionService.php +++ b/app/Modules/Inventory/Services/StoreRequisitionService.php @@ -53,15 +53,22 @@ class StoreRequisitionService // 靜默建立以抑制自動日誌 $requisition->saveQuietly(); + $itemsToInsert = []; + $productIds = collect($items)->pluck('product_id')->unique()->toArray(); + $products = \App\Modules\Inventory\Models\Product::whereIn('id', $productIds)->get()->keyBy('id'); + $diff = ['added' => [], 'removed' => [], 'updated' => []]; foreach ($items as $item) { - $requisition->items()->create([ + $itemsToInsert[] = [ + 'store_requisition_id' => $requisition->id, 'product_id' => $item['product_id'], 'requested_qty' => $item['requested_qty'], 'remark' => $item['remark'] ?? null, - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; - $product = \App\Modules\Inventory\Models\Product::find($item['product_id']); + $product = $products->get($item['product_id']); $diff['added'][] = [ 'product_name' => $product?->name ?? '未知商品', 'new' => [ @@ -70,6 +77,7 @@ class StoreRequisitionService ] ]; } + StoreRequisitionItem::insert($itemsToInsert); // 如果需直接提交,觸發通知 if ($submitImmediately) { @@ -179,13 +187,18 @@ class StoreRequisitionService // 儲存實際變動 $requisition->items()->delete(); + $itemsToInsert = []; foreach ($items as $item) { - $requisition->items()->create([ + $itemsToInsert[] = [ + 'store_requisition_id' => $requisition->id, 'product_id' => $item['product_id'], 'requested_qty' => $item['requested_qty'], 'remark' => $item['remark'] ?? null, - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; } + StoreRequisitionItem::insert($itemsToInsert); // 檢查是否有任何變動 (主表或明細) $isDirty = $requisition->isDirty(); @@ -314,6 +327,7 @@ class StoreRequisitionService $supplyWarehouseId = $requisition->supply_warehouse_id; $totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId) ->where('product_id', $reqItem->product_id) + ->lockForUpdate() // 補上鎖定 ->selectRaw('SUM(quantity - reserved_quantity) as available') ->value('available') ?? 0; diff --git a/app/Modules/Inventory/Services/TransferService.php b/app/Modules/Inventory/Services/TransferService.php index a2c106b..fb8b33b 100644 --- a/app/Modules/Inventory/Services/TransferService.php +++ b/app/Modules/Inventory/Services/TransferService.php @@ -74,11 +74,12 @@ class TransferService return [$key => $item]; }); - // 釋放舊明細的預扣庫存 + // 釋放舊明細的預扣庫存 (必須加鎖,防止並發更新時數量出錯) foreach ($order->items as $item) { $inv = Inventory::where('warehouse_id', $order->from_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) + ->lockForUpdate() ->first(); if ($inv) { $inv->releaseReservedQuantity($item->quantity); @@ -91,42 +92,69 @@ class TransferService 'updated' => [], ]; + // 先刪除舊明細 $order->items()->delete(); + + $itemsToInsert = []; $newItemsKeys = []; + // 1. 批量收集待插入的明細數據 foreach ($itemsData as $data) { $key = $data['product_id'] . '_' . ($data['batch_number'] ?? ''); $newItemsKeys[] = $key; - $item = $order->items()->create([ + $itemsToInsert[] = [ + 'transfer_order_id' => $order->id, 'product_id' => $data['product_id'], 'batch_number' => $data['batch_number'] ?? null, 'quantity' => $data['quantity'], 'position' => $data['position'] ?? null, 'notes' => $data['notes'] ?? null, - ]); - $item->load('product'); + 'created_at' => now(), + 'updated_at' => now(), + ]; + } - // 增加新明細的預扣庫存 - $inv = Inventory::firstOrCreate( - [ + // 2. 執行批量寫入 (提升效能:100 筆明細只需 1 次寫入) + if (!empty($itemsToInsert)) { + InventoryTransferItem::insert($itemsToInsert); + } + + // 3. 重新載入明細進行預扣處理與 Diff 計算 (因 insert 不返回 Model) + $order->load(['items.product.baseUnit']); + + foreach ($order->items as $item) { + $key = $item->product_id . '_' . ($item->batch_number ?? ''); + + // 增加新明細的預扣庫存 (使用 lockForUpdate 確保並發安全) + $inv = Inventory::where('warehouse_id', $order->from_warehouse_id) + ->where('product_id', $item->product_id) + ->where('batch_number', $item->batch_number) + ->lockForUpdate() + ->first(); + + if (!$inv) { + $inv = Inventory::create([ 'warehouse_id' => $order->from_warehouse_id, 'product_id' => $item->product_id, 'batch_number' => $item->batch_number, - ], - [ 'quantity' => 0, 'unit_cost' => 0, 'total_value' => 0, - ] - ); + ]); + $inv = $inv->fresh()->lockForUpdate(); + } + $inv->reserveQuantity($item->quantity); + // 計算 Diff 用於日誌 + $data = collect($itemsData)->first(fn($d) => $d['product_id'] == $item->product_id && ($d['batch_number'] ?? '') == ($item->batch_number ?? '')); + if ($oldItemsMap->has($key)) { $oldItem = $oldItemsMap->get($key); - if ((float)$oldItem->quantity !== (float)$data['quantity'] || - $oldItem->notes !== ($data['notes'] ?? null) || - $oldItem->position !== ($data['position'] ?? null)) { + if ((float)$oldItem->quantity !== (float)$item->quantity || + $oldItem->notes !== $item->notes || + $oldItem->position !== $item->position) { $diff['updated'][] = [ 'product_name' => $item->product->name, @@ -137,7 +165,7 @@ class TransferService 'notes' => $oldItem->notes, ], 'new' => [ - 'quantity' => (float)$data['quantity'], + 'quantity' => (float)$item->quantity, 'position' => $item->position, 'notes' => $item->notes, ] @@ -158,8 +186,8 @@ class TransferService foreach ($oldItemsMap as $key => $oldItem) { if (!in_array($key, $newItemsKeys)) { $diff['removed'][] = [ - 'product_name' => $oldItem->product->name, - 'unit_name' => $oldItem->product->baseUnit?->name, + 'product_name' => $oldItem->product?->name ?? "未知商品 (ID: {$oldItem->product_id})", + 'unit_name' => $oldItem->product?->baseUnit?->name, 'old' => [ 'quantity' => (float)$oldItem->quantity, 'notes' => $oldItem->notes, @@ -179,9 +207,6 @@ class TransferService /** * 出貨 (Dispatch) - 根據是否有在途倉決定流程 - * - * 有在途倉:來源倉扣除 → 在途倉增加,狀態改為 dispatched - * 無在途倉:來源倉扣除 → 目的倉增加,狀態改為 completed(維持原有邏輯) */ public function dispatch(InventoryTransferOrder $order, int $userId): void { @@ -194,18 +219,16 @@ class TransferService $targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id; $targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse; - $outType = '調撥出庫'; - $inType = $hasTransit ? '在途入庫' : '調撥入庫'; - $itemsDiff = []; foreach ($order->items as $item) { if ($item->quantity <= 0) continue; - // 1. 處理來源倉 (扣除) + // 1. 處理來源倉 (扣除) - 使用 lockForUpdate 防止超賣 $sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) + ->lockForUpdate() ->first(); if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) { @@ -235,11 +258,11 @@ class TransferService $sourceAfter = $sourceBefore - (float) $item->quantity; - // 2. 處理目的倉/在途倉 (增加) - // 獲取目的倉異動前的庫存數(若無則為 0) + // 2. 處理目的倉/在途倉 (增加) - 同樣需要鎖定,防止並發增加時出現 Race Condition $targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) + ->lockForUpdate() ->first(); $targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0; @@ -310,7 +333,6 @@ class TransferService /** * 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加 - * 僅適用於有在途倉且狀態為 dispatched 的調撥單 */ public function receive(InventoryTransferOrder $order, int $userId): void { @@ -333,10 +355,11 @@ class TransferService foreach ($order->items as $item) { if ($item->quantity <= 0) continue; - // 1. 在途倉扣除 + // 1. 在途倉扣除 - 使用 lockForUpdate 防止超賣 $transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) + ->lockForUpdate() ->first(); if (!$transitInventory || $transitInventory->quantity < $item->quantity) { @@ -359,10 +382,11 @@ class TransferService $transitAfter = $transitBefore - (float) $item->quantity; - // 2. 目的倉增加 + // 2. 目的倉增加 - 同樣需要鎖定 $targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) + ->lockForUpdate() ->first(); $targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0; @@ -440,6 +464,7 @@ class TransferService $inv = Inventory::where('warehouse_id', $order->from_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) + ->lockForUpdate() ->first(); if ($inv) { $inv->releaseReservedQuantity($item->quantity); diff --git a/app/Modules/Inventory/Services/TurnoverService.php b/app/Modules/Inventory/Services/TurnoverService.php index 88f3637..c715a39 100644 --- a/app/Modules/Inventory/Services/TurnoverService.php +++ b/app/Modules/Inventory/Services/TurnoverService.php @@ -69,6 +69,12 @@ class TurnoverService ->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d')) ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') ->where('inventory_transactions.type', '出庫') // Adjust type as needed based on actual data + ->where(function ($q) { + $q->whereIn('inventory_transactions.reference_type', [ + \App\Modules\Integration\Models\SalesOrder::class, + \App\Modules\Sales\Models\SalesImportBatch::class, + ])->orWhereNull('inventory_transactions.reference_type'); + }) ->where('inventory_transactions.actual_time', '>=', $thirtyDaysAgo) ->groupBy('inventories.product_id'); @@ -87,6 +93,12 @@ class TurnoverService ->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date')) ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') ->where('inventory_transactions.type', '出庫') + ->where(function ($q) { + $q->whereIn('inventory_transactions.reference_type', [ + \App\Modules\Integration\Models\SalesOrder::class, + \App\Modules\Sales\Models\SalesImportBatch::class, + ])->orWhereNull('inventory_transactions.reference_type'); + }) ->groupBy('inventories.product_id'); if ($warehouseId) { @@ -199,6 +211,12 @@ class TurnoverService // Get IDs of products sold in last 90 days $soldProductIds = InventoryTransaction::query() ->where('type', '出庫') + ->where(function ($q) { + $q->whereIn('reference_type', [ + \App\Modules\Integration\Models\SalesOrder::class, + \App\Modules\Sales\Models\SalesImportBatch::class, + ])->orWhereNull('reference_type'); + }) ->where('actual_time', '>=', $ninetyDaysAgo) ->distinct() ->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product. @@ -214,6 +232,12 @@ class TurnoverService $soldProductIdsQuery = DB::table('inventory_transactions') ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') ->where('inventory_transactions.type', '出庫') + ->where(function ($q) { + $q->whereIn('inventory_transactions.reference_type', [ + \App\Modules\Integration\Models\SalesOrder::class, + \App\Modules\Sales\Models\SalesImportBatch::class, + ])->orWhereNull('inventory_transactions.reference_type'); + }) ->where('inventory_transactions.actual_time', '>=', $ninetyDaysAgo) ->select('inventories.product_id') ->distinct(); @@ -236,6 +260,12 @@ class TurnoverService ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') ->join('products', 'inventories.product_id', '=', 'products.id') ->where('inventory_transactions.type', '出庫') + ->where(function ($q) { + $q->whereIn('inventory_transactions.reference_type', [ + \App\Modules\Integration\Models\SalesOrder::class, + \App\Modules\Sales\Models\SalesImportBatch::class, + ])->orWhereNull('inventory_transactions.reference_type'); + }) ->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays($analysisDays)) ->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId)) ->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId)) diff --git a/app/Modules/Procurement/Controllers/PurchaseOrderController.php b/app/Modules/Procurement/Controllers/PurchaseOrderController.php index dd6b72f..0f8e202 100644 --- a/app/Modules/Procurement/Controllers/PurchaseOrderController.php +++ b/app/Modules/Procurement/Controllers/PurchaseOrderController.php @@ -118,7 +118,7 @@ class PurchaseOrderController extends Controller public function create() { // 1. 獲取廠商(無關聯) - $vendors = Vendor::all(); + $vendors = Vendor::select('id', 'name')->get(); // 2. 手動注入:獲取 Pivot 資料 $vendorIds = $vendors->pluck('id')->toArray(); @@ -254,17 +254,21 @@ class PurchaseOrderController extends Controller $productIds = collect($validated['items'])->pluck('productId')->unique()->toArray(); $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + $itemsToInsert = []; foreach ($validated['items'] as $item) { // 反算單價 $unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0; - $order->items()->create([ + $itemsToInsert[] = [ + 'purchase_order_id' => $order->id, 'product_id' => $item['productId'], 'quantity' => $item['quantity'], 'unit_id' => $item['unitId'] ?? null, 'unit_price' => $unitPrice, 'subtotal' => $item['subtotal'], - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; $product = $products->get($item['productId']); $diff['added'][] = [ @@ -275,6 +279,7 @@ class PurchaseOrderController extends Controller ] ]; } + \App\Modules\Procurement\Models\PurchaseOrderItem::insert($itemsToInsert); // 手動發送高品質日誌(包含品項明細) activity() @@ -379,7 +384,7 @@ class PurchaseOrderController extends Controller $order = PurchaseOrder::with(['items'])->findOrFail($id); // 2. 獲取廠商與商品(與 create 邏輯一致) - $vendors = Vendor::all(); + $vendors = Vendor::select('id', 'name')->get(); $vendorIds = $vendors->pluck('id')->toArray(); $pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get(); $productIds = $pivots->pluck('product_id')->unique()->toArray(); @@ -468,7 +473,8 @@ class PurchaseOrderController extends Controller public function update(Request $request, $id) { - $order = PurchaseOrder::findOrFail($id); + // 加上 lockForUpdate() 防止併發修改 + $order = PurchaseOrder::lockForUpdate()->findOrFail($id); $validated = $request->validate([ 'vendor_id' => 'required|exists:vendors,id', @@ -572,20 +578,23 @@ class PurchaseOrderController extends Controller // 同步項目(原始邏輯) $order->items()->delete(); - $newItemsData = []; + $itemsToInsert = []; foreach ($validated['items'] as $item) { // 反算單價 $unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0; - $newItem = $order->items()->create([ + $itemsToInsert[] = [ + 'purchase_order_id' => $order->id, 'product_id' => $item['productId'], 'quantity' => $item['quantity'], 'unit_id' => $item['unitId'] ?? null, 'unit_price' => $unitPrice, 'subtotal' => $item['subtotal'], - ]); - $newItemsData[] = $newItem; + 'created_at' => now(), + 'updated_at' => now(), + ]; } + \App\Modules\Procurement\Models\PurchaseOrderItem::insert($itemsToInsert); // 3. 計算項目差異 $itemDiffs = [ diff --git a/app/Modules/Procurement/Controllers/PurchaseReturnController.php b/app/Modules/Procurement/Controllers/PurchaseReturnController.php index c54d0e6..a0d72d8 100644 --- a/app/Modules/Procurement/Controllers/PurchaseReturnController.php +++ b/app/Modules/Procurement/Controllers/PurchaseReturnController.php @@ -48,7 +48,7 @@ class PurchaseReturnController extends Controller { // 取得可用的倉庫與廠商資料供前端選單使用 $warehouses = $this->inventoryService->getAllWarehouses(); - $vendors = Vendor::all(); + $vendors = Vendor::select('id', 'name')->get(); // 手動注入:獲取廠商商品 (與 PurchaseOrderController 邏輯一致) $vendorIds = $vendors->pluck('id')->toArray(); @@ -157,7 +157,7 @@ class PurchaseReturnController extends Controller }); $warehouses = $this->inventoryService->getAllWarehouses(); - $vendors = Vendor::all(); + $vendors = Vendor::select('id', 'name')->get(); // 手動注入:獲取廠商商品 (與 create 邏輯一致) $vendorIds = $vendors->pluck('id')->toArray(); diff --git a/app/Modules/Procurement/Services/PurchaseReturnService.php b/app/Modules/Procurement/Services/PurchaseReturnService.php index cd2b0cf..fbfcc1b 100644 --- a/app/Modules/Procurement/Services/PurchaseReturnService.php +++ b/app/Modules/Procurement/Services/PurchaseReturnService.php @@ -33,20 +33,23 @@ class PurchaseReturnService $purchaseReturn = PurchaseReturn::create($data); + $itemsToInsert = []; foreach ($data['items'] as $itemData) { $amount = $itemData['quantity_returned'] * $itemData['unit_price']; $totalAmount += $amount; - $prItem = new PurchaseReturnItem([ + $itemsToInsert[] = [ + 'purchase_return_id' => $purchaseReturn->id, 'product_id' => $itemData['product_id'], 'quantity_returned' => $itemData['quantity_returned'], 'unit_price' => $itemData['unit_price'], 'total_amount' => $amount, 'batch_number' => $itemData['batch_number'] ?? null, - ]); - - $purchaseReturn->items()->save($prItem); + 'created_at' => now(), + 'updated_at' => now(), + ]; } + PurchaseReturnItem::insert($itemsToInsert); // 更新總計 (這裡假定不含額外稅金邏輯,或是由前端帶入 tax_amount) $taxAmount = $data['tax_amount'] ?? 0; @@ -87,19 +90,23 @@ class PurchaseReturnService $purchaseReturn->items()->delete(); $totalAmount = 0; + $itemsToInsert = []; foreach ($data['items'] as $itemData) { $amount = $itemData['quantity_returned'] * $itemData['unit_price']; $totalAmount += $amount; - $prItem = new PurchaseReturnItem([ + $itemsToInsert[] = [ + 'purchase_return_id' => $purchaseReturn->id, 'product_id' => $itemData['product_id'], 'quantity_returned' => $itemData['quantity_returned'], 'unit_price' => $itemData['unit_price'], 'total_amount' => $amount, 'batch_number' => $itemData['batch_number'] ?? null, - ]); - $purchaseReturn->items()->save($prItem); + 'created_at' => now(), + 'updated_at' => now(), + ]; } + PurchaseReturnItem::insert($itemsToInsert); $taxAmount = $purchaseReturn->tax_amount; $purchaseReturn->update([ @@ -117,11 +124,14 @@ class PurchaseReturnService */ public function submit(PurchaseReturn $purchaseReturn) { - if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) { - throw new Exception('只有草稿狀態的退回單可以提交。'); - } - return DB::transaction(function () use ($purchaseReturn) { + // 加上 lockForUpdate() 防止併發提交 + $purchaseReturn = PurchaseReturn::lockForUpdate()->find($purchaseReturn->id); + + if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) { + throw new Exception('只有草稿狀態的退回單可以提交。'); + } + // 1. 儲存狀態,避免觸發自動修改紀錄 (合併行為) $purchaseReturn->status = PurchaseReturn::STATUS_COMPLETED; $purchaseReturn->saveQuietly(); diff --git a/app/Modules/Production/Controllers/ProductionOrderController.php b/app/Modules/Production/Controllers/ProductionOrderController.php index dca13f8..16b7daa 100644 --- a/app/Modules/Production/Controllers/ProductionOrderController.php +++ b/app/Modules/Production/Controllers/ProductionOrderController.php @@ -134,15 +134,15 @@ class ProductionOrderController extends Controller public function store(Request $request) { $status = $request->input('status', 'draft'); - + $rules = [ 'product_id' => 'required', - 'status' => 'nullable|in:draft,completed', - 'warehouse_id' => $status === 'completed' ? 'required' : 'nullable', - 'output_quantity' => $status === 'completed' ? 'required|numeric|min:0.01' : 'nullable|numeric', + 'status' => 'nullable|in:draft,pending,completed', + 'warehouse_id' => 'required', + 'output_quantity' => 'required|numeric|min:0.01', 'items' => 'nullable|array', - 'items.*.inventory_id' => $status === 'completed' ? 'required' : 'nullable', - 'items.*.quantity_used' => $status === 'completed' ? 'required|numeric|min:0.0001' : 'nullable|numeric', + 'items.*.inventory_id' => 'required', + 'items.*.quantity_used' => 'required|numeric|min:0.0001', ]; $validated = $request->validate($rules); @@ -159,7 +159,7 @@ class ProductionOrderController extends Controller 'production_date' => $request->production_date, 'expiry_date' => $request->expiry_date, 'user_id' => auth()->id(), - 'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿 + 'status' => $status ?: ProductionOrder::STATUS_DRAFT, 'remark' => $request->remark, ]); @@ -170,14 +170,18 @@ class ProductionOrderController extends Controller // 2. 處理明細 if (!empty($request->items)) { + $itemsToInsert = []; foreach ($request->items as $item) { - ProductionOrderItem::create([ + $itemsToInsert[] = [ 'production_order_id' => $productionOrder->id, 'inventory_id' => $item['inventory_id'], 'quantity_used' => $item['quantity_used'] ?? 0, 'unit_id' => $item['unit_id'] ?? null, - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; } + ProductionOrderItem::insert($itemsToInsert); } }); @@ -380,14 +384,18 @@ class ProductionOrderController extends Controller $productionOrder->items()->delete(); if (!empty($request->items)) { + $itemsToInsert = []; foreach ($request->items as $item) { - ProductionOrderItem::create([ + $itemsToInsert[] = [ 'production_order_id' => $productionOrder->id, 'inventory_id' => $item['inventory_id'], 'quantity_used' => $item['quantity_used'] ?? 0, 'unit_id' => $item['unit_id'] ?? null, - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; } + ProductionOrderItem::insert($itemsToInsert); } }); @@ -406,9 +414,30 @@ class ProductionOrderController extends Controller return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403); } + // 送審前的資料完整性驗證 + if ($productionOrder->status === ProductionOrder::STATUS_DRAFT && $newStatus === ProductionOrder::STATUS_PENDING) { + if (!$productionOrder->output_quantity || $productionOrder->output_quantity <= 0) { + return back()->with('error', '送審工單前,必須先編輯填寫「生產數量」'); + } + if (!$productionOrder->warehouse_id) { + return back()->with('error', '送審工單前,必須先編輯選擇「預計入庫倉庫」'); + } + if ($productionOrder->items()->count() === 0) { + return back()->with('error', '送審工單前,請至少新增一項原物料明細'); + } + } + DB::transaction(function () use ($newStatus, $productionOrder, $request) { + // 使用鎖定重新獲取單據,防止併發狀態修改 + $productionOrder = ProductionOrder::where('id', $productionOrder->id)->lockForUpdate()->first(); + $oldStatus = $productionOrder->status; + // 再次檢查狀態轉移(在鎖定後) + if (!$productionOrder->canTransitionTo($newStatus)) { + throw new \Exception('不合法的狀態轉移或權限不足'); + } + // 1. 執行特定狀態的業務邏輯 if ($oldStatus === ProductionOrder::STATUS_APPROVED && $newStatus === ProductionOrder::STATUS_IN_PROGRESS) { // 開始製作 -> 扣除原料庫存 @@ -428,6 +457,8 @@ class ProductionOrderController extends Controller $warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來 $batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來 $expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來 + $actualOutputQuantity = $request->input('actual_output_quantity'); // 實際產出數量 + $lossReason = $request->input('loss_reason'); // 耗損原因 if (!$warehouseId) { throw new \Exception('必須選擇入庫倉庫'); @@ -435,8 +466,14 @@ class ProductionOrderController extends Controller if (!$batchNumber) { throw new \Exception('必須提供成品批號'); } + if (!$actualOutputQuantity || $actualOutputQuantity <= 0) { + throw new \Exception('實際產出數量必須大於 0'); + } + if ($actualOutputQuantity > $productionOrder->output_quantity) { + throw new \Exception('實際產出數量不可大於預計產量'); + } - // --- 新增:計算原物料投入總成本 --- + // --- 計算原物料投入總成本 --- $totalCost = 0; $items = $productionOrder->items()->with('inventory')->get(); foreach ($items as $item) { @@ -445,23 +482,25 @@ class ProductionOrderController extends Controller } } - // 計算單位成本 (若產出數量為 0 則設為 0 避免除以零錯誤) - $unitCost = $productionOrder->output_quantity > 0 - ? $totalCost / $productionOrder->output_quantity + // 單位成本以「實際產出數量」為分母,反映真實生產效率 + $unitCost = $actualOutputQuantity > 0 + ? $totalCost / $actualOutputQuantity : 0; - // -------------------------------- - // 更新單據資訊:批號、效期與自動記錄生產日期 + // 更新單據資訊:批號、效期、實際產量與耗損原因 $productionOrder->output_batch_number = $batchNumber; $productionOrder->expiry_date = $expiryDate; $productionOrder->production_date = now()->toDateString(); $productionOrder->warehouse_id = $warehouseId; + $productionOrder->actual_output_quantity = $actualOutputQuantity; + $productionOrder->loss_reason = $lossReason; + // 成品入庫數量改用「實際產出數量」 $this->inventoryService->createInventoryRecord([ 'warehouse_id' => $warehouseId, 'product_id' => $productionOrder->product_id, - 'quantity' => $productionOrder->output_quantity, - 'unit_cost' => $unitCost, // 傳入計算後的單位成本 + 'quantity' => $actualOutputQuantity, + 'unit_cost' => $unitCost, 'batch_number' => $batchNumber, 'box_number' => $productionOrder->output_box_count, 'arrival_date' => now()->toDateString(), diff --git a/app/Modules/Production/Models/ProductionOrder.php b/app/Modules/Production/Models/ProductionOrder.php index bc95e0d..c409034 100644 --- a/app/Modules/Production/Models/ProductionOrder.php +++ b/app/Modules/Production/Models/ProductionOrder.php @@ -24,6 +24,8 @@ class ProductionOrder extends Model 'product_id', 'warehouse_id', 'output_quantity', + 'actual_output_quantity', + 'loss_reason', 'output_batch_number', 'output_box_count', 'production_date', @@ -82,6 +84,7 @@ class ProductionOrder extends Model 'production_date' => 'date', 'expiry_date' => 'date', 'output_quantity' => 'decimal:2', + 'actual_output_quantity' => 'decimal:2', ]; public function getActivitylogOptions(): LogOptions @@ -91,6 +94,8 @@ class ProductionOrder extends Model 'code', 'status', 'output_quantity', + 'actual_output_quantity', + 'loss_reason', 'output_batch_number', 'production_date', 'remark' diff --git a/app/Modules/Sales/Controllers/SalesImportController.php b/app/Modules/Sales/Controllers/SalesImportController.php index 01d2d35..f66cd3c 100644 --- a/app/Modules/Sales/Controllers/SalesImportController.php +++ b/app/Modules/Sales/Controllers/SalesImportController.php @@ -101,11 +101,13 @@ class SalesImportController extends Controller public function confirm(SalesImportBatch $import, InventoryServiceInterface $inventoryService) { - if ($import->status !== 'pending') { - return back()->with('error', '此批次無法確認。'); - } + return DB::transaction(function () use ($import, $inventoryService) { + // 加上 lockForUpdate() 防止併發確認 + $import = SalesImportBatch::lockForUpdate()->find($import->id); - DB::transaction(function () use ($import, $inventoryService) { + if (!$import || $import->status !== 'pending') { + throw new \Exception('此批次無法確認或已被處理。'); + } // 1. Prepare Aggregation $aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot" @@ -155,7 +157,9 @@ class SalesImportController extends Controller $deduction['quantity'], $reason, true, // Force deduction - $deduction['slot'] // Location/Slot + $deduction['slot'], // Location/Slot + \App\Modules\Sales\Models\SalesImportBatch::class, + $import->id ); } diff --git a/database/migrations/tenant/2026_03_06_113117_create_utility_fee_attachments_table.php b/database/migrations/tenant/2026_03_06_113117_create_utility_fee_attachments_table.php new file mode 100644 index 0000000..a36bb2f --- /dev/null +++ b/database/migrations/tenant/2026_03_06_113117_create_utility_fee_attachments_table.php @@ -0,0 +1,32 @@ +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'); + } +}; diff --git a/database/migrations/tenant/2026_03_09_141431_add_performance_indexes_to_tenant_tables.php b/database/migrations/tenant/2026_03_09_141431_add_performance_indexes_to_tenant_tables.php new file mode 100644 index 0000000..a063484 --- /dev/null +++ b/database/migrations/tenant/2026_03_09_141431_add_performance_indexes_to_tenant_tables.php @@ -0,0 +1,112 @@ +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']); + }); + } +}; diff --git a/database/migrations/tenant/2026_03_10_140000_add_actual_output_to_production_orders.php b/database/migrations/tenant/2026_03_10_140000_add_actual_output_to_production_orders.php new file mode 100644 index 0000000..4c80abc --- /dev/null +++ b/database/migrations/tenant/2026_03_10_140000_add_actual_output_to_production_orders.php @@ -0,0 +1,37 @@ +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']); + }); + } +}; diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts new file mode 100644 index 0000000..b0d716b --- /dev/null +++ b/e2e/admin.spec.ts @@ -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(); + }); +}); diff --git a/e2e/finance.spec.ts b/e2e/finance.spec.ts new file mode 100644 index 0000000..6d218f0 --- /dev/null +++ b/e2e/finance.spec.ts @@ -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(); + }); +}); diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..5797efd --- /dev/null +++ b/e2e/helpers/auth.ts @@ -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 }); +} diff --git a/e2e/integration.spec.ts b/e2e/integration.spec.ts new file mode 100644 index 0000000..805353e --- /dev/null +++ b/e2e/integration.spec.ts @@ -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(); + }); +}); diff --git a/e2e/inventory.spec.ts b/e2e/inventory.spec.ts new file mode 100644 index 0000000..fe6393c --- /dev/null +++ b/e2e/inventory.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; +import * as path from 'path'; +import * as fs from 'fs'; + +/** + * 庫存模組端到端測試 + */ +test.describe('庫存管理 - 調撥單匯入', () => { + // 登入 + 導航 + 匯入全流程需要較長時間 + test.use({ actionTimeout: 15000 }); + + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('應能成功匯入調撥單明細', async ({ page }) => { + // 整體測試逾時設定為 60 秒 + test.setTimeout(60000); + // 1. 前往調撥單列表 + await page.goto('/inventory/transfer-orders'); + await expect(page.getByText('庫存調撥管理')).toBeVisible(); + + // 2. 等待表格載入並尋找特定的 E2E 測試單據 + await page.waitForSelector('table tbody tr'); + + const draftRow = page.locator('tr:has-text("TRF-E2E-FINAL")').first(); + const hasDraft = await draftRow.count() > 0; + + if (hasDraft) { + // 點擊 "編輯" 按鈕 + await draftRow.locator('button[title="編輯"], a:has-text("編輯")').first().click(); + } else { + throw new Error('測試環境中找不到單號為 TRF-E2E-FINAL 的調撥單。'); + } + + // 3. 驗證已進入詳情頁 (標題包含調撥單單號) + await expect(page.getByRole('heading', { name: /調撥單: TRF-/ })).toBeVisible({ timeout: 15000 }); + + // 4. 開啟匯入對話框 + const importBtn = page.getByRole('button', { name: /匯入 Excel|匯入/ }); + await expect(importBtn).toBeVisible(); + await importBtn.click(); + + await expect(page.getByText('匯入調撥明細')).toBeVisible(); + + // 5. 準備測試檔案 (CSV 格式) + const csvPath = path.join('/tmp', 'transfer_import_test.csv'); + // 欄位名稱必須與後端匹配,商品代碼使用 P2 (紅糖) + const csvContent = "商品代碼,數量,批號,備註\nP2,10,BATCH001,E2E Test Import\n"; + fs.writeFileSync(csvPath, csvContent); + + // 6. 執行上傳 + await page.setInputFiles('input[type="file"]', csvPath); + + // 7. 點擊開始匯入 + await page.getByRole('button', { name: '開始匯入' }).click(); + + // 8. 等待頁面更新 (Inertia reload) + await page.waitForTimeout(3000); + + // 9. 驗證詳情頁表格是否出現匯入的資料 + // 注意:「E2E Test Import」是 input 的 value,不是靜態文字,hasText 無法匹配 input value + // 因此先找包含 P2 文字的行(P2 是靜態 text),再驗證備註 input 的值 + const p2Row = page.locator('table tbody tr').filter({ hasText: 'P2' }).first(); + await expect(p2Row).toBeVisible({ timeout: 15000 }); + + // 驗證備註欄位的 input value 包含測試標記 + // 快照中備註欄位的 role 是 textbox,placeholder 是 "備註..." + const remarkInput = p2Row.getByRole('textbox', { name: '備註...' }); + await expect(remarkInput).toHaveValue('E2E Test Import'); + + // 截圖留存 + if (!fs.existsSync('e2e/screenshots')) fs.mkdirSync('e2e/screenshots', { recursive: true }); + await page.screenshot({ path: 'e2e/screenshots/inventory-transfer-import-success.png', fullPage: true }); + + // 清理臨時檔案 + if (fs.existsSync(csvPath)) fs.unlinkSync(csvPath); + }); + +}); diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts new file mode 100644 index 0000000..bc623b1 --- /dev/null +++ b/e2e/login.spec.ts @@ -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(); + }); + +}); diff --git a/e2e/procurement.spec.ts b/e2e/procurement.spec.ts new file mode 100644 index 0000000..2f3c740 --- /dev/null +++ b/e2e/procurement.spec.ts @@ -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 }); + }); +}); diff --git a/e2e/production.spec.ts b/e2e/production.spec.ts new file mode 100644 index 0000000..55b72da --- /dev/null +++ b/e2e/production.spec.ts @@ -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(); + }); +}); diff --git a/e2e/products.spec.ts b/e2e/products.spec.ts new file mode 100644 index 0000000..1571ab7 --- /dev/null +++ b/e2e/products.spec.ts @@ -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(); + }); +}); diff --git a/e2e/sales.spec.ts b/e2e/sales.spec.ts new file mode 100644 index 0000000..937150b --- /dev/null +++ b/e2e/sales.spec.ts @@ -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(); + }); +}); diff --git a/e2e/vendors.spec.ts b/e2e/vendors.spec.ts new file mode 100644 index 0000000..58dccbf --- /dev/null +++ b/e2e/vendors.spec.ts @@ -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(); + }); +}); diff --git a/e2e/warehouses.spec.ts b/e2e/warehouses.spec.ts new file mode 100644 index 0000000..6b7ca00 --- /dev/null +++ b/e2e/warehouses.spec.ts @@ -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(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 16bf295..b2dee62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "tailwind-merge": "^3.4.0" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@types/node": "^25.0.3", "@types/react": "^19.2.7", @@ -83,6 +84,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -846,6 +848,22 @@ "@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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2847,6 +2865,7 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2856,6 +2875,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2866,6 +2886,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3001,6 +3022,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3285,7 +3307,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3-array": { "version": "3.2.4", @@ -5379,6 +5402,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5386,6 +5410,53 @@ "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": { "version": "8.5.6", "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", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5475,6 +5547,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5539,6 +5612,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5669,7 +5743,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -6035,7 +6110,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -6360,6 +6436,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index ae0ff06..b6fa688 100644 --- a/package.json +++ b/package.json @@ -1,59 +1,60 @@ { - "$schema": "https://www.schemastore.org/package.json", - "name": "star-erp", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "dev": "vite" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.0.0", - "@types/node": "^25.0.3", - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "axios": "^1.11.0", - "concurrently": "^9.0.1", - "laravel-vite-plugin": "^2.0.0", - "tailwindcss": "^4.0.0", - "typescript": "^5.9.3", - "vite": "^7.0.7" - }, - "dependencies": { - "@inertiajs/react": "^2.3.4", - "@radix-ui/react-accordion": "^1.2.12", - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-tooltip": "^1.2.8", - "@tailwindcss/typography": "^0.5.19", - "@types/lodash": "^4.17.21", - "@vitejs/plugin-react": "^5.1.2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "date-fns": "^4.1.0", - "jsbarcode": "^3.12.1", - "lodash": "^4.17.21", - "lucide-react": "^0.562.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-hot-toast": "^2.6.0", - "react-markdown": "^10.1.0", - "recharts": "^3.7.0", - "remark-gfm": "^4.0.1", - "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0" - } + "$schema": "https://www.schemastore.org/package.json", + "name": "star-erp", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@playwright/test": "^1.58.2", + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^25.0.3", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.9.3", + "vite": "^7.0.7" + }, + "dependencies": { + "@inertiajs/react": "^2.3.4", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/typography": "^0.5.19", + "@types/lodash": "^4.17.21", + "@vitejs/plugin-react": "^5.1.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "jsbarcode": "^3.12.1", + "lodash": "^4.17.21", + "lucide-react": "^0.562.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hot-toast": "^2.6.0", + "react-markdown": "^10.1.0", + "recharts": "^3.7.0", + "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0" + } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..3563eb1 --- /dev/null +++ b/playwright.config.ts @@ -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'] }, + }, + ], +}); diff --git a/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx b/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx index c500cb6..9e2adda 100644 --- a/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx +++ b/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx @@ -1,5 +1,6 @@ /** * 生產工單完工入庫 - 選擇倉庫彈窗 + * 含產出確認與耗損記錄功能 */ import React from 'react'; @@ -8,7 +9,8 @@ import { Button } from "@/Components/ui/button"; import { SearchableSelect } from "@/Components/ui/searchable-select"; import { Input } from "@/Components/ui/input"; import { Label } from "@/Components/ui/label"; -import { Warehouse as WarehouseIcon, Calendar as CalendarIcon, Tag, X, CheckCircle2 } from "lucide-react"; +import { Warehouse as WarehouseIcon, AlertTriangle, Tag, CalendarIcon, CheckCircle2, X } from "lucide-react"; +import { formatQuantity } from "@/lib/utils"; interface Warehouse { id: number; @@ -22,12 +24,18 @@ interface WarehouseSelectionModalProps { warehouseId: number; batchNumber: string; expiryDate: string; + actualOutputQuantity: number; + lossReason: string; }) => void; warehouses: Warehouse[]; processing?: boolean; - // 新增商品資訊以利產生批號 + // 商品資訊用於產生批號 productCode?: string; productId?: number; + // 預計產量(用於耗損計算) + outputQuantity: number; + // 成品單位名稱 + unitName?: string; } export default function WarehouseSelectionModal({ @@ -38,10 +46,22 @@ export default function WarehouseSelectionModal({ processing = false, productCode, productId, + outputQuantity, + unitName = '', }: WarehouseSelectionModalProps) { const [selectedId, setSelectedId] = React.useState(null); const [batchNumber, setBatchNumber] = React.useState(""); const [expiryDate, setExpiryDate] = React.useState(""); + const [actualOutputQuantity, setActualOutputQuantity] = React.useState(""); + const [lossReason, setLossReason] = React.useState(""); + + // 當開啟時,初始化實際產出數量為預計產量 + React.useEffect(() => { + if (isOpen) { + setActualOutputQuantity(String(outputQuantity)); + setLossReason(""); + } + }, [isOpen, outputQuantity]); // 當開啟時,嘗試產生成品批號 (若有資訊) React.useEffect(() => { @@ -62,26 +82,37 @@ export default function WarehouseSelectionModal({ } }, [isOpen, productCode, productId]); + // 計算耗損數量 + const actualQty = parseFloat(actualOutputQuantity) || 0; + const lossQuantity = outputQuantity - actualQty; + const hasLoss = lossQuantity > 0; + const handleConfirm = () => { - if (selectedId && batchNumber) { + if (selectedId && batchNumber && actualQty > 0) { onConfirm({ warehouseId: selectedId, batchNumber, - expiryDate + expiryDate, + actualOutputQuantity: actualQty, + lossReason: hasLoss ? lossReason : '', }); } }; + // 驗證:實際產出不可大於預計產量,也不可小於等於 0 + const isActualQtyValid = actualQty > 0 && actualQty <= outputQuantity; + return ( !open && onClose()}> - + - 選擇完工入庫倉庫 + 完工入庫確認
+ {/* 倉庫選擇 */}
+ {/* 成品批號 */}
+ {/* 成品效期 */}
+ + {/* 分隔線 - 產出確認區 */} +
+

產出確認

+ + {/* 預計產量(唯讀) */} +
+ 預計產量 + + {formatQuantity(outputQuantity)} {unitName} + +
+ + {/* 實際產出數量 */} +
+ +
+ setActualOutputQuantity(e.target.value)} + className={`h-9 font-bold ${!isActualQtyValid && actualOutputQuantity !== '' ? 'border-red-400 focus:ring-red-400' : ''}`} + /> + {unitName && {unitName}} +
+ {actualQty > outputQuantity && ( +

實際產出不可超過預計產量

+ )} +
+ + {/* 耗損顯示 */} + {hasLoss && ( +
+
+ + + 耗損數量:{formatQuantity(lossQuantity)} {unitName} + +
+
+ + setLossReason(e.target.value)} + placeholder="例如:製作過程損耗、品質不合格..." + className="h-9 border-orange-200 focus:ring-orange-400" + /> +
+
+ )} +
+ + + {/* 附件列表 */} +
+ {loading ? ( +
+ +

載入中...

+
+ ) : attachments.length === 0 ? ( +
+

目前尚無附件

+
+ ) : ( +
+ {attachments.map((file) => ( +
+
+
+ {file.mime_type.startsWith("image/") ? ( + {file.original_name} { + (e.target as HTMLImageElement).src = ""; + (e.target as HTMLImageElement).className = "hidden"; + (e.target as HTMLImageElement).parentElement?.classList.add("bg-slate-200"); + }} + /> + ) : ( + + )} +
+
+

+ {file.original_name} +

+

+ {formatSize(file.size)} +

+
+
+ +
+ + +
+
+ ))} +
+ )} +
+ +
+
+ + + + + 確認刪除附件? + + 這將永久刪除此附件,此操作無法撤銷。 + + + + 取消 + + 確認刪除 + + + + + + ); +} diff --git a/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx b/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx index 98e1951..83ee3ee 100644 --- a/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx +++ b/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx @@ -20,13 +20,19 @@ import { validateInvoiceNumber } from "@/utils/validation"; export interface UtilityFee { id: number; - transaction_date: string | null; - due_date: string; - category: string; + billing_month: string; + category_id: number; + category?: string; // 相容於舊版或特定視圖 + category_name?: string; amount: number | string; - payment_status: 'pending' | 'paid' | 'overdue'; + status: string; + payment_status?: 'pending' | 'paid' | 'overdue'; // 相容於舊版 + due_date: string; + payment_date?: string; + transaction_date?: string; // 相容於舊版 invoice_number?: string; description?: string; + attachments_count?: number; created_at: string; updated_at: string; } diff --git a/resources/js/Components/Warehouse/WarehouseDialog.tsx b/resources/js/Components/Warehouse/WarehouseDialog.tsx index fab0c2a..9da26a2 100644 --- a/resources/js/Components/Warehouse/WarehouseDialog.tsx +++ b/resources/js/Components/Warehouse/WarehouseDialog.tsx @@ -266,14 +266,13 @@ export default function WarehouseDialog({ {/* 倉庫地址 */}
setFormData({ ...formData, address: e.target.value })} placeholder="例:台北市信義區信義路五段7號" - required className="h-9" />
diff --git a/resources/js/Components/shared/Pagination.tsx b/resources/js/Components/shared/Pagination.tsx index 7a9cbd6..35eed90 100644 --- a/resources/js/Components/shared/Pagination.tsx +++ b/resources/js/Components/shared/Pagination.tsx @@ -27,22 +27,33 @@ export default function Pagination({ links, className }: PaginationProps) { const isNext = label === "Next"; const activeIndex = links.findIndex(l => l.active); - // Tablet/Mobile visibility logic (< md): - // Show: Previous, Next, Active, and up to 2 neighbors (Total ~5 numeric pages) - // Hide others on small screens (hidden md:flex) - // User requested: "small than 800... display 5 pages" - const isVisibleOnTablet = + // Responsive visibility logic: + // Global: Previous, Next, Active are always visible + // Mobile (< sm): Active, +-1, First, Last, and Ellipses + // Tablet (sm < md): Active, +-2, First, Last, and Ellipses + // Desktop (>= md): All standard pages + const isFirst = key === 1; + const isLast = key === links.length - 2; + const isEllipsis = !isPrevious && !isNext && !link.url; + + const isMobileVisible = isPrevious || isNext || link.active || + isFirst || + isLast || + isEllipsis || key === activeIndex - 1 || - key === activeIndex + 1 || + key === activeIndex + 1; + + const isTabletVisible = + isMobileVisible || key === activeIndex - 2 || key === activeIndex + 2; const baseClasses = cn( - isVisibleOnTablet ? "flex" : "hidden md:flex", - "h-9 items-center justify-center rounded-md border px-3 text-sm" + "h-9 items-center justify-center rounded-md border px-3 text-sm", + isMobileVisible ? "flex" : (isTabletVisible ? "hidden sm:flex md:flex" : "hidden md:flex") ); // 如果是 Previous/Next 但沒有 URL,則不渲染(或者渲染為 disabled) diff --git a/resources/js/Components/ui/searchable-select.tsx b/resources/js/Components/ui/searchable-select.tsx index 44dbe1e..4c8b570 100644 --- a/resources/js/Components/ui/searchable-select.tsx +++ b/resources/js/Components/ui/searchable-select.tsx @@ -38,6 +38,8 @@ interface SearchableSelectProps { showSearch?: boolean; /** 是否可清除選取 */ isClearable?: boolean; + /** 是否為無效狀態(顯示紅色邊框) */ + "aria-invalid"?: boolean; } export function SearchableSelect({ @@ -52,6 +54,7 @@ export function SearchableSelect({ searchThreshold = 10, showSearch, isClearable = false, + "aria-invalid": ariaInvalid, }: SearchableSelectProps) { const [open, setOpen] = React.useState(false); @@ -79,12 +82,15 @@ export function SearchableSelect({ !selectedOption && "text-grey-3", // Focus state - primary border with ring "focus-visible:border-primary-main focus-visible:ring-primary-main/20 focus-visible:ring-[3px]", + // Error state + ariaInvalid && "border-destructive ring-destructive/20", // Disabled state "disabled:border-grey-4 disabled:bg-background-light-grey disabled:text-grey-2 disabled:cursor-not-allowed disabled:opacity-50", // Height "h-9", className )} + aria-invalid={ariaInvalid} > {selectedOption ? selectedOption.label : placeholder} diff --git a/resources/js/Pages/Accounting/Report.tsx b/resources/js/Pages/Accounting/Report.tsx index 30a7923..879862f 100644 --- a/resources/js/Pages/Accounting/Report.tsx +++ b/resources/js/Pages/Accounting/Report.tsx @@ -8,7 +8,7 @@ import { Calendar, Filter, TrendingDown, - Package, + Wallet, Pocket, RotateCcw, FileText @@ -29,6 +29,7 @@ import Pagination from "@/Components/shared/Pagination"; import { SearchableSelect } from "@/Components/ui/searchable-select"; import { Can } from "@/Components/Permission/Can"; import { Checkbox } from "@/Components/ui/checkbox"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; interface Record { id: string; @@ -37,8 +38,14 @@ interface Record { category: string; item: string; reference: string; - invoice_number?: string; + invoice_date?: string | null; + invoice_number?: string | null; amount: number | string; + tax_amount: number | string; + status?: string; + payment_method?: string | null; + payment_note?: string | null; + remarks?: string | null; } interface PageProps { @@ -52,7 +59,7 @@ interface PageProps { }; summary: { total_amount: number; - purchase_total: number; + payable_total: number; utility_total: number; record_count: number; }; @@ -273,10 +280,10 @@ export default function AccountingReport({ records, summary, filters }: PageProp
- +
- 採購支出 - $ {Number(summary.purchase_total).toLocaleString()} + 應付帳款 + $ {Number(summary.payable_total).toLocaleString()}
@@ -305,13 +312,16 @@ export default function AccountingReport({ records, summary, filters }: PageProp 來源 類別 項目詳細 - 金額 + 付款方式 / 備註 + 狀態 + 稅額 + 總金額 {records.data.length === 0 ? ( - +

此日期區間內無支出紀錄

@@ -333,7 +343,7 @@ export default function AccountingReport({ records, summary, filters }: PageProp @@ -348,11 +358,47 @@ export default function AccountingReport({ records, summary, filters }: PageProp
{record.item} - {record.invoice_number && ( - 發票:{record.invoice_number} + {(record.invoice_number || record.invoice_date) && ( + + 發票:{record.invoice_number || '-'} + {record.invoice_date && ` (${record.invoice_date})`} + + )} + {record.remarks && ( + + 備註:{record.remarks} + )}
+ +
+ {record.payment_method || '-'} + {record.payment_note && ( + + {record.payment_note} + + )} +
+
+ + {record.status === 'paid' ? ( + 已付款 + ) : record.status === 'pending' ? ( + 待付款 + ) : record.status === 'overdue' ? ( + 已逾期 + ) : record.status === 'draft' ? ( + 草稿 + ) : record.status === 'approved' ? ( + 已核准 + ) : ( + {record.status || '-'} + )} + + + {record.tax_amount ? `$ ${Number(record.tax_amount).toLocaleString()}` : '-'} + $ {Number(record.amount).toLocaleString()} diff --git a/resources/js/Pages/Admin/ActivityLog/Index.tsx b/resources/js/Pages/Admin/ActivityLog/Index.tsx index ef9962a..ca0011c 100644 --- a/resources/js/Pages/Admin/ActivityLog/Index.tsx +++ b/resources/js/Pages/Admin/ActivityLog/Index.tsx @@ -318,10 +318,10 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u from={activities.from} /> -
-
+
+
- 每頁顯示 + 每頁顯示 - +
- 共 {activities.total} 筆資料 + 共 {activities.total} 筆資料
-
+
diff --git a/resources/js/Pages/Production/Create.tsx b/resources/js/Pages/Production/Create.tsx index 3b819db..6e83959 100644 --- a/resources/js/Pages/Production/Create.tsx +++ b/resources/js/Pages/Production/Create.tsx @@ -96,11 +96,11 @@ export default function Create({ products, warehouses }: Props) { const [recipes, setRecipes] = useState([]); const [selectedRecipeId, setSelectedRecipeId] = useState(""); - const { data, setData, processing, errors } = useForm({ + // 提交表單 + const { data, setData, processing, errors, setError, clearErrors } = useForm({ product_id: "", warehouse_id: "", output_quantity: "", - // 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄 remark: "", items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], }); @@ -108,7 +108,6 @@ export default function Create({ products, warehouses }: Props) { // 獲取特定商品在各倉庫的庫存分佈 const fetchProductInventories = async (productId: string) => { if (!productId) return; - // 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數 if (loadingProducts[productId]) return; setLoadingProducts(prev => ({ ...prev, [productId]: true })); @@ -159,7 +158,6 @@ export default function Create({ products, warehouses }: Props) { item.unit_id = ""; item.ui_input_quantity = ""; item.ui_selected_unit = "base"; - // 清除 cache 資訊 delete item.ui_product_name; delete item.ui_batch_number; delete item.ui_available_qty; @@ -180,21 +178,13 @@ export default function Create({ products, warehouses }: Props) { } } - // 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量) if (field === 'ui_warehouse_id') { item.inventory_id = ""; - // 不重置數量 - // item.quantity_used = ""; - // item.ui_input_quantity = ""; - // item.ui_selected_unit = "base"; - - // 清除某些 cache delete item.ui_batch_number; delete item.ui_available_qty; delete item.ui_expiry_date; } - // 3. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊 if (field === 'inventory_id' && value) { const currentOptions = productInventoryMap[item.ui_product_id] || []; const inv = currentOptions.find(i => String(i.id) === value); @@ -203,45 +193,31 @@ export default function Create({ products, warehouses }: Props) { item.ui_batch_number = inv.batch_number; item.ui_available_qty = inv.quantity; item.ui_expiry_date = inv.expiry_date || ''; - - // 單位與轉換率 item.ui_base_unit_name = inv.unit_name || ''; item.ui_base_unit_id = inv.base_unit_id; item.ui_large_unit_id = inv.large_unit_id; item.ui_purchase_unit_id = inv.purchase_unit_id; item.ui_conversion_rate = inv.conversion_rate || 1; item.ui_unit_cost = inv.unit_cost || 0; - - // 預設單位 item.ui_selected_unit = 'base'; item.unit_id = String(inv.base_unit_id || ''); - - // 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方) if (!item.ui_input_quantity) { item.ui_input_quantity = formatQuantity(inv.quantity); } } } - // 4. 計算最終數量 (Base Quantity) if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') { const inputQty = parseFloat(item.ui_input_quantity || '0'); const rate = item.ui_conversion_rate || 1; - - if (item.ui_selected_unit === 'large') { - item.quantity_used = String(inputQty * rate); - item.unit_id = String(item.ui_base_unit_id || ''); - } else { - item.quantity_used = String(inputQty); - item.unit_id = String(item.ui_base_unit_id || ''); - } + item.quantity_used = item.ui_selected_unit === 'large' ? String(inputQty * rate) : String(inputQty); + item.unit_id = String(item.ui_base_unit_id || ''); } updated[index] = item; setBomItems(updated); }; - // 同步 BOM items 到表單 data useEffect(() => { setData('items', bomItems.map(item => ({ inventory_id: Number(item.inventory_id), @@ -250,33 +226,22 @@ export default function Create({ products, warehouses }: Props) { }))); }, [bomItems]); - // 應用配方到表單 (獨立函式) const applyRecipe = (recipe: any) => { if (!recipe || !recipe.items) return; - const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1; - // 自動帶入配方標準產量 setData('output_quantity', formatQuantity(yieldQty)); - const newBomItems: BomItem[] = recipe.items.map((item: any) => { - const baseQty = parseFloat(item.quantity || "0"); - const calculatedQty = baseQty; // 保持精度 - - // 若有配方商品,預先載入庫存分佈 - if (item.product_id) { - fetchProductInventories(String(item.product_id)); - } - + if (item.product_id) fetchProductInventories(String(item.product_id)); return { inventory_id: "", - quantity_used: String(calculatedQty), + quantity_used: String(item.quantity || "0"), unit_id: String(item.unit_id), ui_warehouse_id: "", ui_product_id: String(item.product_id), ui_product_name: item.product_name, ui_batch_number: "", ui_available_qty: 0, - ui_input_quantity: formatQuantity(calculatedQty), + ui_input_quantity: formatQuantity(item.quantity || "0"), ui_selected_unit: 'base', ui_base_unit_name: item.unit_name, ui_base_unit_id: item.unit_id, @@ -284,45 +249,30 @@ export default function Create({ products, warehouses }: Props) { }; }); setBomItems(newBomItems); - - toast.success(`已自動載入配方: ${recipe.name}`, { - description: `標準產量: ${formatQuantity(yieldQty)} 份` - }); + toast.success(`已自動載入配方: ${recipe.name}`); }; - // 當手動切換配方時 useEffect(() => { if (!selectedRecipeId) return; const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId); - if (targetRecipe) { - applyRecipe(targetRecipe); - } + if (targetRecipe) applyRecipe(targetRecipe); }, [selectedRecipeId]); - // 自動產生成品批號與載入配方 useEffect(() => { if (!data.product_id) return; - - // 2. 自動載入配方列表 const fetchRecipes = async () => { try { - // 改為抓取所有配方 const res = await fetch(route('api.production.recipes.by-product', data.product_id)); const recipesData = await res.json(); - if (Array.isArray(recipesData) && recipesData.length > 0) { setRecipes(recipesData); - // 預設選取最新的 (第一個) - const latest = recipesData[0]; - setSelectedRecipeId(String(latest.id)); + setSelectedRecipeId(String(recipesData[0].id)); } else { - // 若無配方 setRecipes([]); setSelectedRecipeId(""); - setBomItems([]); // 清空 BOM + setBomItems([]); } } catch (e) { - console.error("Failed to fetch recipes", e); setRecipes([]); setBomItems([]); } @@ -330,61 +280,74 @@ export default function Create({ products, warehouses }: Props) { fetchRecipes(); }, [data.product_id]); - // 當生產數量變動時,如果是從配方載入的,則按比例更新用量 + // 當有驗證錯誤時,自動聚焦到第一個錯誤欄位 useEffect(() => { - if (bomItems.length > 0 && data.output_quantity) { - // 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號 - // 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾 - // 但如果是剛載入(inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性 + const errorKeys = Object.keys(errors); + if (errorKeys.length > 0) { + // 延遲一下確保 DOM 已更新(例如 BOM 明細行渲染) + setTimeout(() => { + const firstInvalid = document.querySelector('[aria-invalid="true"]'); + if (firstInvalid instanceof HTMLElement) { + firstInvalid.focus(); + firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 100); } - }, [data.output_quantity]); + }, [errors]); - // 提交表單 - const submit = (status: 'draft' | 'completed') => { - // 驗證(簡單前端驗證,完整驗證在後端) - if (status === 'completed') { - const missingFields = []; - if (!data.product_id) missingFields.push('成品商品'); - if (!data.output_quantity) missingFields.push('生產數量'); - if (!selectedWarehouse) missingFields.push('預計入庫倉庫'); - if (bomItems.length === 0) missingFields.push('原物料明細'); + const submit = (status: 'draft') => { + clearErrors(); + let hasError = false; - if (missingFields.length > 0) { - toast.error("請填寫必要欄位", { - description: `缺漏:${missingFields.join('、')}` - }); - return; + // 草稿建立時也要求必填生產數量與預計入庫倉庫 + if (!data.product_id) { setError('product_id', '請選擇成品商品'); hasError = true; } + if (!data.output_quantity) { setError('output_quantity', '請輸入生產數量'); hasError = true; } + if (!selectedWarehouse) { setError('warehouse_id', '請選擇預計入庫倉庫'); hasError = true; } + if (bomItems.length === 0) { toast.error("請至少新增一項原物料明細"); hasError = true; } + + // 驗證 BOM 明細 + bomItems.forEach((item, index) => { + if (!item.ui_product_id) { + setError(`items.${index}.ui_product_id` as any, '請選擇商品'); + hasError = true; + } else { + if (!item.inventory_id) { + setError(`items.${index}.inventory_id` as any, '請選擇批號'); + hasError = true; + } + if (!item.quantity_used || parseFloat(item.quantity_used) <= 0) { + setError(`items.${index}.quantity_used` as any, '請輸入數量'); + hasError = true; + } } + }); + + if (hasError) { + toast.error("建立失敗,請檢查標單內紅框欄位"); + return; } - // 轉換 BOM items 格式 - const formattedItems = bomItems - .filter(item => status === 'draft' || (item.inventory_id && item.quantity_used)) - .map(item => ({ - inventory_id: item.inventory_id ? parseInt(item.inventory_id) : null, - quantity_used: item.quantity_used ? parseFloat(item.quantity_used) : 0, - unit_id: item.unit_id ? parseInt(item.unit_id) : null, - })); + const formattedItems = bomItems.map(item => ({ + inventory_id: parseInt(item.inventory_id), + quantity_used: parseFloat(item.quantity_used), + unit_id: item.unit_id ? parseInt(item.unit_id) : null, + })); - // 使用 router.post 提交完整資料 router.post(route('production-orders.store'), { ...data, warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null, items: formattedItems, status: status, }, { - onError: (errors) => { - const errorCount = Object.keys(errors).length; - toast.error("建立失敗,請檢查表單", { - description: `共有 ${errorCount} 個欄位有誤,請修正後再試` - }); + onError: () => { + toast.error("建立失敗,請檢查表單"); } }); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - submit('completed'); + submit('draft'); }; const getBomItemUnitCost = (item: BomItem) => { @@ -423,7 +386,7 @@ export default function Create({ products, warehouses }: Props) {
-

+

建立生產工單

@@ -431,14 +394,18 @@ export default function Create({ products, warehouses }: Props) { 建立新的生產排程,選擇原物料並記錄產出

- +
+ +
@@ -458,6 +425,7 @@ export default function Create({ products, warehouses }: Props) { }))} placeholder="選擇成品" className="w-full h-9" + aria-invalid={!!errors.product_id} /> {errors.product_id &&

{errors.product_id}

} @@ -493,6 +461,7 @@ export default function Create({ products, warehouses }: Props) { onChange={(e) => setData('output_quantity', e.target.value)} placeholder="例如: 50" className="h-9 font-mono" + aria-invalid={!!errors.output_quantity} /> {errors.output_quantity &&

{errors.output_quantity}

}
@@ -508,6 +477,7 @@ export default function Create({ products, warehouses }: Props) { }))} placeholder="選擇倉庫" className="w-full h-9" + aria-invalid={!!errors.warehouse_id} /> {errors.warehouse_id &&

{errors.warehouse_id}

}
@@ -600,6 +570,7 @@ export default function Create({ products, warehouses }: Props) { options={productOptions} placeholder="選擇商品" className="w-full" + aria-invalid={!!errors[`items.${index}.ui_product_id` as any]} />
@@ -628,6 +599,7 @@ export default function Create({ products, warehouses }: Props) { placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"} className="w-full" disabled={!item.ui_warehouse_id} + aria-invalid={!!errors[`items.${index}.inventory_id` as any]} /> {item.inventory_id && (() => { const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id); @@ -655,6 +627,7 @@ export default function Create({ products, warehouses }: Props) { placeholder="0" className="h-9 text-right" disabled={!item.inventory_id} + aria-invalid={!!errors[`items.${index}.quantity_used` as any]} /> diff --git a/resources/js/Pages/Production/Index.tsx b/resources/js/Pages/Production/Index.tsx index 5a633c3..9ca8101 100644 --- a/resources/js/Pages/Production/Index.tsx +++ b/resources/js/Pages/Production/Index.tsx @@ -2,8 +2,9 @@ * 生產工單管理主頁面 */ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Plus, Factory, Search, Eye, Pencil, Trash2 } from 'lucide-react'; +import { debounce } from "lodash"; import { formatQuantity } from "@/lib/utils"; import { Button } from "@/Components/ui/button"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; @@ -77,16 +78,25 @@ export default function ProductionIndex({ productionOrders, filters }: Props) { setPerPage(filters.per_page || productionOrders.per_page?.toString() || "10"); }, [filters]); - const handleFilter = () => { - router.get( - route('production-orders.index'), - { - search, - status: status === 'all' ? undefined : status, - per_page: perPage, - }, - { preserveState: true, replace: true, preserveScroll: true } - ); + const debouncedFilter = useCallback( + debounce((params: any) => { + router.get(route("production-orders.index"), params, { + preserveState: true, + replace: true, + preserveScroll: true, + }); + }, 300), + [] + ); + + const handleSearchChange = (term: string) => { + setSearch(term); + debouncedFilter({ + ...filters, + search: term, + status: status === "all" ? undefined : status, + per_page: perPage, + }); }; @@ -129,16 +139,12 @@ export default function ProductionIndex({ productionOrders, filters }: Props) { setSearch(e.target.value)} + onChange={(e) => handleSearchChange(e.target.value)} className="pl-10 pr-10 h-9" - onKeyDown={(e) => e.key === 'Enter' && handleFilter()} /> {search && ( - - +