diff --git a/.agents/rules/ui-consistency.md b/.agents/rules/ui-consistency.md index ef6ad15..ea34fe7 100644 --- a/.agents/rules/ui-consistency.md +++ b/.agents/rules/ui-consistency.md @@ -7,506 +7,93 @@ name: 客戶端後台 UI 統一規範 description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為 --- -## 概述 +## 適用範圍 -本技能提供 Star ERP 系統**客戶端(租戶端)後台**的 UI 統一性規範,確保所有頁面使用一致的元件、樣式類別、圖標和佈局模式。 +本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。 -> **適用範圍**:本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。 +## 核心禁止事項 -## 核心原則 - -1. **使用統一的 UI 組件庫**:優先使用 `@/Components/ui/` 中的 47 個元件 -2. **遵循既定的樣式類別**:使用 `app.css` 中定義的自定義按鈕類別 -3. **統一的圖標系統**:全面使用 `lucide-react` 圖標 -4. **一致的佈局模式**:表格、分頁、操作按鈕等保持相同結構 -5. **權限控制**:所有操作按鈕必須使用 `` 元件包裹 +- ❌ **禁止 Hardcode 色碼**(如 `text-[#01ab83]`),必須使用 `*-primary-main` 等 Tailwind Class 或 CSS 變數 +- ❌ **禁止使用非 `lucide-react` 的圖標庫**(如 FontAwesome、Material Icons) +- ❌ **禁止操作按鈕不包裹 `` 權限元件** --- -## 1. 專案結構 +## 1. 色彩系統 -### 1.1 關鍵目錄 +### 主題色(動態租戶品牌色,由 `AuthenticatedLayout` 自動注入) -``` -resources/ -├── css/ -│ └── app.css # 全域樣式與設計 Token -├── js/ -│ ├── Components/ -│ │ ├── ui/ # 47 個基礎 UI 元件 (shadcn/ui) -│ │ ├── shared/ # 共用業務元件 (Pagination, BreadcrumbNav 等) -│ │ └── Permission/ # 權限控制元件 (Can, HasRole, CanAll) -│ ├── Layouts/ -│ │ ├── AuthenticatedLayout.tsx # 客戶端後台佈局 ⬅️ 本規範適用 -│ │ └── LandlordLayout.tsx # 中央管理後台佈局 -│ └── Pages/ # 頁面元件 -``` +| Tailwind Class | 用途 | +|---|---| +| `*-primary-main` | 主色:按鈕、連結、強調 | +| `*-primary-dark` | Hover 狀態 | +| `*-primary-light` | 次要強調 | +| `*-primary-lightest` | 背景底色、Active 狀態 | -### 1.2 可用 UI 元件清單 +### 灰階與狀態色 -``` -accordion, alert, alert-dialog, avatar, badge, breadcrumb, button, -calendar, card, carousel, chart, checkbox, collapsible, command, -context-menu, dialog, drawer, dropdown-menu, form, hover-card, -input, input-otp, label, menubar, navigation-menu, pagination, -popover, progress, radio-group, resizable, scroll-area, -searchable-select, select, separator, sheet, sidebar, skeleton, -slider, sonner, switch, table, tabs, textarea, toggle, toggle-group, -tooltip -``` +直接參考 `resources/css/app.css` 中定義的 `--grey-0` ~ `--grey-5` 與 `--other-success/error/warning/info` 變數。 --- -## 2. 色彩系統 +## 2. 按鈕規範 -### 2.1 主題色 (Primary) - **動態租戶品牌色** +樣式定義於 `resources/css/app.css`,按鈕必須使用以下類別: -> **注意**:主題色會根據租戶設定(Branding)動態改變,**嚴禁**在程式碼中 Hardcode 色碼(如 `#01ab83`)。 -> 請務必使用 Tailwind Utility Class 或 CSS 變數。 +| 類型 | 類別 | 用途 | +|---|---|---| +| Filled | `button-filled-primary` | 主要操作(新增、儲存) | +| Filled | `button-filled-success/info/warning/error` | 各狀態操作 | +| Outlined | `button-outlined-primary` | 次要操作(編輯、檢視) | +| Outlined | `button-outlined-error` | 刪除按鈕 | +| Text | `button-text-primary` | 文字連結式按鈕 | -| Tailwind Class | CSS Variable | 說明 | -|----------------|--------------|------| -| `*-primary-main` | `--primary-main` | **主色**:與租戶設定一致(預設綠色),用於主要按鈕、連結、強調文字 | -| `*-primary-dark` | `--primary-dark` | **深色**:系統自動計算,用於 Hover 狀態 | -| `*-primary-light` | `--primary-light` | **淺色**:系統自動計算,用於次要強調 | -| `*-primary-lightest` | `--primary-lightest` | **最淺色**:系統自動計算,用於背景底色、Active 狀態 | +**尺寸**:表格操作列用 `size="sm"`,一般操作用 `size="default"`,主要 CTA 用 `size="lg"`。 -**運作機制**: -`AuthenticatedLayout` 會根據後端回傳的 `branding` 資料,自動注入 CSS 變數覆寫預設值。 - -```tsx -// ✅ 正確:使用 Tailwind Class -
...
- -// ✅ 正確:使用 CSS 變數 (自定義樣式時) -
...
- -// ❌ 錯誤:寫死色碼 (會導致租戶無法換色) -
...
-``` - -### 2.2 灰階 (Grey Scale) - -```css ---grey-0: #1a1a1a; /* 深黑 - 標題文字 */ ---grey-1: #4a4a4a; /* 深灰 - 主要內文 */ ---grey-2: #6b6b6b; /* 中灰 - 次要內文、Placeholder */ ---grey-3: #9e9e9e; /* 淺灰 - 禁用文字、輔助說明 */ ---grey-4: #e0e0e0; /* 極淺灰 - 邊框、分隔線 */ ---grey-5: #fff; /* 白色 - 背景、按鈕文字 */ -``` - -### 2.3 狀態色 (State Colors) - -```css ---other-success: #01ab83; /* 成功 - 同主題色 */ ---other-error: #dc2626; /* 錯誤 - 刪除、警示 */ ---other-warning: #f59e0b; /* 警告 - 提醒、注意 */ ---other-info: #3b82f6; /* 資訊 - 說明、提示 */ -``` +**返回按鈕**:放置於標題上方,使用 `variant="outline"` + `className="gap-2 button-outlined-primary"`,搭配 `` 圖標。 --- -## 3. 按鈕規範 +## 3. 圖標規範 -### 3.1 按鈕樣式類別 +統一使用 `lucide-react`。 -專案在 `resources/css/app.css` 中定義了統一的按鈕樣式,**必須**使用這些類別: +| 尺寸 | 用途 | +|---|---| +| `h-4 w-4` | 按鈕內、表格操作 | +| `h-5 w-5` | 側邊欄選單 | +| `h-6 w-6` | 頁面標題 | -#### Filled 按鈕(實心按鈕)— 用於主要操作 - -```tsx -// ✅ 主要操作按鈕(綠色主題色)- 新增、儲存、確認 - - -// ✅ 成功操作 - - -// ✅ 資訊操作(用於系統提示、說明等非業務主流程) - - -// ✅ 警告操作 - - -// ✅ 錯誤/刪除操作(AlertDialog 內確認按鈕) - -``` - -#### Outlined 按鈕(邊框按鈕)— 用於次要操作 - -```tsx -// ✅ 編輯按鈕(表格操作列) - - -// ✅ 刪除按鈕(表格操作列) - -``` - -#### Text 按鈕(文字按鈕) - -```tsx - -``` - -### 3.2 按鈕大小 - -| Size | 高度 | 使用情境 | -|------|------|----------| -| `size="sm"` | h-8 | 表格操作列、緊湊佈局 | -| `size="default"` | h-9 | 一般操作、表單提交 | -| `size="lg"` | h-10 | 主要 CTA、頁面主操作 | -| `size="icon"` | 9×9 | 純圖標按鈕 | - -### 3.3 常見操作按鈕模式 - -#### 頁面頂部新增按鈕 - -```tsx - - - - - -``` - -#### 表格操作列檢視按鈕 - -```tsx - - - - - -``` - -#### 表格操作列編輯按鈕 - -```tsx - - - - - -``` - -#### 表格操作列刪除按鈕(帶確認對話框) - -```tsx - - - - - - - - 確認刪除 - - 確定要刪除「{item.name}」嗎?此操作無法復原。 - - - - 取消 - handleDelete(item.id)} - className="bg-red-600 hover:bg-red-700" - > - 刪除 - - - - - -``` - -### 3.4 返回按鈕規範 - -詳情頁面(如:查看庫存、進貨單詳情)的返回按鈕應統一放置於 **頁面標題上方**,並採用「**圖標 + 文字**」的 Outlined 樣式。 - -**樣式規格**: -- **位置**:標題區域上方 (`mb-6`),獨立於標題列 -- **樣式**:`variant="outline"` + `className="gap-2 button-outlined-primary"` -- **圖標**:`` -- **文字**:清楚說明返回目的地,例如「返回倉庫管理」、「返回列表」 - -```tsx -
- - - -
-``` +常用映射:`Plus`(新增)、`Pencil`(編輯)、`Trash2`(刪除)、`Eye`(檢視)、`Search`(搜尋)、`ArrowLeft`(返回)。 +其餘請參考 `AuthenticatedLayout.tsx` 中的 `allMenuItems` 定義。 --- -## 3.5 頁面佈局規範(新增/編輯頁面) +## 4. 頁面佈局規範 -### 標準結構 +所有頁面遵循以下結構,參考範例:`Pages/Product/Create.tsx`、`Pages/PurchaseOrder/Create.tsx`。 -新增/編輯頁面(如:商品新增、採購單建立)應遵循以下標準結構: - -```tsx - - - -
- {/* Header */} -
- {/* 返回按鈕 */} - - - - - {/* 頁面標題區塊 */} -
-

- - 頁面標題 -

-

- 頁面說明文字 -

-
-
- - {/* 表單或內容區塊 */} - -
-
-``` - -### 關鍵規範 - -1. **外層容器**:使用 `className="container mx-auto p-6 max-w-7xl"` 確保寬度與間距一致 -2. **Header 包裹**:使用 `
` 包裹返回按鈕與標題區塊 -3. **返回按鈕**:加上 `mb-4` 與標題區塊分隔 -4. **標題區塊**:使用 `
` 包裹 h1 和 p 標籤 -5. **標題樣式**:`text-2xl font-bold text-grey-0 flex items-center gap-2` -6. **說明文字**:`text-gray-500 mt-1` - -### 範例頁面 - -- ✅ `/resources/js/Pages/PurchaseOrder/Create.tsx`(建立採購單) -- ✅ `/resources/js/Pages/Product/Create.tsx`(新增商品) -- ✅ `/resources/js/Pages/Product/Edit.tsx`(編輯商品) - ---- - -## 4. 圖標規範 - -### 4.1 統一使用 lucide-react - -**統一使用 `lucide-react`**,禁止使用其他圖標庫(如 FontAwesome、Material Icons、react-icons 等)。 - -### 4.2 圖標尺寸標準 - -| 尺寸 | 類別 | 使用情境 | -|------|------|----------| -| 小型 | `h-3 w-3` | Badge 內、小文字旁 | -| 標準 | `h-4 w-4` | 按鈕內、表格操作 | -| 標題 | `h-5 w-5` | 側邊欄選單 | -| 大型 | `h-6 w-6` | 頁面標題 | - -### 4.3 常用操作圖標映射 - -| 操作 | 圖標組件 | 使用情境 | -|------|----------|----------| -| 新增 | `` | 新增按鈕 | -| 編輯 | `` | 編輯按鈕 | -| 刪除 | `` | 刪除按鈕 | -| 查看 | `` | 查看詳情 | -| 搜尋 | `` | 搜尋欄位 | -| 篩選 | `` | 篩選功能 | -| 下載 | `` | 下載/匯出 | -| 上傳 | `` | 上傳/匯入 | -| 設定 | `` | 設定功能 | -| 複製 | `` | 複製內容 | -| 郵件 | `` | Email 顯示 | -| 使用者 | ``, `` | 使用者管理 | -| 權限 | `` | 角色/權限 | -| 排序 | ``, ``, `` | 表格排序 | -| 儀表板 | `` | 首頁/總覽 | -| 商品 | `` | 商品管理 | -| 倉庫 | `` | 倉庫管理 | -| 廠商 | ``, `` | 廠商管理 | -| 採購 | `` | 採購管理 | - -### 4.4 圖標使用範例 - -```tsx -import { Plus, Pencil, Trash2, Users } from 'lucide-react'; - -// 頁面標題 -

- - 使用者管理 -

- -// 按鈕內圖標(圖標在左,帶文字) - - -// 純圖標按鈕(表格操作列) - -``` +**關鍵規則**: +- **外層容器**:`className="container mx-auto p-6 max-w-7xl"` +- **標題樣式**:`text-2xl font-bold text-grey-0 flex items-center gap-2` +- **說明文字**:`text-gray-500 mt-1` +- **麵包屑**:使用 `BreadcrumbItemType`(屬性為 `label`, `href`, `isPage`),不需要包含「首頁」 +- **Input 元件**:不額外設定 focus 樣式,直接使用 `@/Components/ui/input` 的內建樣式 +- **日期顯示**:使用 `resources/js/lib/date.ts` 的 `formatDate` 工具 --- ## 5. 表格規範 -### 5.1 表格容器 +**容器**:`bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden` +**標題列**:`bg-gray-50`,序號欄 `w-[50px] text-center`,操作欄置中 +**空狀態**:`text-center py-8 text-gray-500`,顯示「無符合條件的資料」 +**操作欄**:`flex items-center justify-center gap-2` -```tsx -
- - {/* 表格內容 */} -
-
-``` +### 排序(三態切換) -### 5.2 表格標題列 - -```tsx - - - # - 名稱 - 操作 - - -``` - -**關鍵要點**: -- 使用 `bg-gray-50` 背景色 -- 序號欄位固定寬度 `w-[50px]` 並置中 -- 操作欄位置中顯示 - -### 5.3 表格主體 - -```tsx - - {items.length === 0 ? ( - - - 無符合條件的資料 - - - ) : ( - items.map((item, index) => ( - - - {startIndex + index} - - {/* 其他欄位 */} - -
- {/* 操作按鈕 */} -
-
-
- )) - )} -
-``` - -**關鍵要點**: -- 空狀態訊息使用置中、灰色文字 -- 序號欄使用 `text-gray-500 font-medium text-center` -- 操作欄使用 `flex items-center justify-center gap-2` 排列按鈕 - -### 5.4 欄位排序規範 - -當表格需要支援排序時,請遵循以下模式: - -1. **圖標邏輯**: - * 未排序:`ArrowUpDown` (class: `text-muted-foreground`) - * 升冪 (asc):`ArrowUp` (class: `text-primary`) - * 降冪 (desc):`ArrowDown` (class: `text-primary`) -2. **結構**:在 `TableHead` 內使用 `button` 元素。 -3. **後端配合**:後端 Controller **必須** 處理 `sort_by` 與 `sort_order` 參數。 - -```tsx -// 1. 定義 Helper Component (在元件內部) -const SortIcon = ({ field }: { field: string }) => { - if (filters.sort_by !== field) { - return ; - } - if (filters.sort_order === "asc") { - return ; - } - return ; -}; - -// 2. 表格標題應用 - - - - -// 3. 排序處理函式 (三態切換:未排序 -> 升冪 -> 降冪 -> 未排序) -const handleSort = (field: string) => { - let newSortBy: string | undefined = field; - let newSortOrder: 'asc' | 'desc' | undefined = 'asc'; - - if (filters.sort_by === field) { - if (filters.sort_order === 'asc') { - newSortOrder = 'desc'; - } else { - // desc -> reset (回到預設排序) - newSortBy = undefined; - newSortOrder = undefined; - } - } - - router.get( - route(route().curr \ No newline at end of file +- 未排序:`ArrowUpDown`(`text-muted-foreground`) +- 升冪:`ArrowUp`(`text-primary`) +- 降冪:`ArrowDown`(`text-primary`) +- 後端必須處理 `sort_by` 與 `sort_order` 參數 +- 參考實作:`Pages/Product/Index.tsx` 的 `handleSort` \ No newline at end of file diff --git a/app/Modules/Core/Controllers/RoleController.php b/app/Modules/Core/Controllers/RoleController.php index 2e97eb8..fb12b97 100644 --- a/app/Modules/Core/Controllers/RoleController.php +++ b/app/Modules/Core/Controllers/RoleController.php @@ -185,6 +185,7 @@ class RoleController extends Controller 'inventory_adjust' => '庫存盤調管理', 'inventory_transfer' => '庫存調撥管理', 'inventory_report' => '庫存報表', + 'inventory_traceability' => '批號溯源', 'vendors' => '廠商資料管理', 'purchase_orders' => '採購單管理', 'purchase_returns' => '採購退回管理', diff --git a/app/Modules/Inventory/Controllers/TraceabilityController.php b/app/Modules/Inventory/Controllers/TraceabilityController.php new file mode 100644 index 0000000..926f4be --- /dev/null +++ b/app/Modules/Inventory/Controllers/TraceabilityController.php @@ -0,0 +1,42 @@ +input('batch_number'); + $direction = $request->input('direction', 'backward'); // backward 或 forward + + $result = null; + + if ($batchNumber) { + if ($direction === 'backward') { + $result = $this->traceabilityService->traceBackward($batchNumber); + } else { + $result = $this->traceabilityService->traceForward($batchNumber); + } + } + + return Inertia::render('Inventory/Traceability/Index', [ + 'search' => [ + 'batch_number' => $batchNumber, + 'direction' => $direction, + ], + 'result' => $result + ]); + } +} diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index e9af2bc..5b7f953 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -38,6 +38,11 @@ Route::middleware('auth')->group(function () { Route::get('/inventory/analysis', [InventoryAnalysisController::class, 'index'])->name('inventory.analysis.index'); }); + // 批號溯源 (Lot Traceability) + Route::middleware('permission:inventory_traceability.view')->group(function () { + Route::get('/inventory/traceability', [\App\Modules\Inventory\Controllers\TraceabilityController::class, 'index'])->name('inventory.traceability.index'); + }); + // 類別管理 (用於商品對話框) - 需要商品權限 Route::middleware('permission:products.view')->group(function () { Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index'); diff --git a/app/Modules/Inventory/Services/StoreRequisitionService.php b/app/Modules/Inventory/Services/StoreRequisitionService.php index 6825108..b1e12f4 100644 --- a/app/Modules/Inventory/Services/StoreRequisitionService.php +++ b/app/Modules/Inventory/Services/StoreRequisitionService.php @@ -122,8 +122,13 @@ class StoreRequisitionService $processedItems = []; // 暫存處理後的明細,用於轉入調撥單 if (isset($data['items'])) { + $requisition->load('items.product'); + $reqItemMap = $requisition->items->keyBy('id'); + foreach ($data['items'] as $itemData) { $reqItemId = $itemData['id']; + $reqItem = $reqItemMap->get($reqItemId); + $productName = $reqItem?->product?->name ?? '未知商品'; $totalApprovedQty = 0; $batches = $itemData['batches'] ?? []; @@ -133,7 +138,22 @@ class StoreRequisitionService foreach ($batches as $batch) { $qty = (float)($batch['qty'] ?? 0); $bNum = $batch['batch_number'] ?? null; + $invId = $batch['inventory_id'] ?? null; + if ($qty > 0) { + if ($invId) { + $inventory = \App\Modules\Inventory\Models\Inventory::lockForUpdate()->find($invId); + if ($inventory) { + $available = max(0, $inventory->quantity - $inventory->reserved_quantity); + if ($qty > $available) { + $batchStr = $bNum ? "批號 {$bNum}" : "無批號"; + throw ValidationException::withMessages([ + 'items' => "「{$productName}」的 {$batchStr} 數量({$qty})不可大於可用庫存({$available})", + ]); + } + } + } + $totalApprovedQty += $qty; $batchKey = $bNum ?? ''; $batchGroups[$batchKey] = ($batchGroups[$batchKey] ?? 0) + $qty; @@ -151,6 +171,18 @@ class StoreRequisitionService // 無批號,傳統輸入 $qty = (float)($itemData['approved_qty'] ?? 0); if ($qty > 0) { + $supplyWarehouseId = $requisition->supply_warehouse_id; + $totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId) + ->where('product_id', $reqItem->product_id) + ->selectRaw('SUM(quantity - reserved_quantity) as available') + ->value('available') ?? 0; + + if ($qty > $totalAvailable) { + throw ValidationException::withMessages([ + 'items' => "「{$productName}」的數量({$qty})不可大於供貨倉可用總庫存({$totalAvailable})", + ]); + } + $totalApprovedQty += $qty; $processedItems[] = [ 'req_item_id' => $reqItemId, diff --git a/app/Modules/Inventory/Services/TraceabilityService.php b/app/Modules/Inventory/Services/TraceabilityService.php new file mode 100644 index 0000000..e16b9cc --- /dev/null +++ b/app/Modules/Inventory/Services/TraceabilityService.php @@ -0,0 +1,326 @@ +where('batch_number', $batchNumber) + ->first(); + + // 定義根節點 + $rootNode = [ + 'id' => 'batch_' . $batchNumber, + 'type' => 'target_batch', + 'label' => '查詢批號: ' . $batchNumber, + 'batch_number' => $batchNumber, + 'product_name' => $baseInventory?->product?->name, + 'spec' => $baseInventory?->product?->spec, + 'warehouse_name' => $baseInventory?->warehouse?->name, + 'children' => [] + ]; + + // 1. 尋找這個批號是不是生產出來的成品 (Production Order Output) + // 透過 ProductionService 獲取,以落實模組解耦 + $productionOrders = $this->productionService->getProductionOrdersByOutputBatch($batchNumber); + + foreach ($productionOrders as $po) { + $poNode = [ + 'id' => 'po_' . $po->id, + 'type' => 'production_order', + 'label' => '生產工單: ' . $po->code, + 'date' => $po->production_date instanceof \DateTimeInterface + ? $po->production_date->format('Y-m-d') + : $po->production_date, + 'quantity' => $po->output_quantity, + 'children' => [] + ]; + + // 針對每一張工單,尋找它投料的原料批號 + foreach ($po->items as $item) { + if (isset($item->inventory)) { + $materialNode = $this->buildMaterialBackwardNode($item->inventory, $item); + $poNode['children'][] = $materialNode; + } + } + $rootNode['children'][] = $poNode; + } + + // 2. 如果這批號是直接採購進來的 (Goods Receipt) + // 或者是為了補足直接查詢原料批號的場景 + $inventories = Inventory::with(['product', 'warehouse']) + ->where('batch_number', $batchNumber) + ->get(); + + foreach ($inventories as $inv) { + // 尋找進貨單 + $grItems = GoodsReceiptItem::with(['goodsReceipt', 'product']) + ->where('batch_number', $batchNumber) + ->where('product_id', $inv->product_id) + ->get(); + + foreach ($grItems as $grItem) { + $gr = $grItem->goodsReceipt; + if ($gr) { + $grNode = [ + 'id' => 'gr_' . $gr->id . '_' . $inv->id, + 'type' => 'goods_receipt', + 'label' => '進貨單: ' . $gr->code, + 'date' => $gr->received_date instanceof \DateTimeInterface + ? $gr->received_date->format('Y-m-d') + : $gr->received_date, + 'vendor_id' => $gr->vendor_id, + 'quantity' => $grItem->quantity, + 'product_name' => $grItem->product?->name, + 'children' => [] + ]; + + // 避免重複加入 + $isDuplicate = false; + foreach ($rootNode['children'] as $child) { + if ($child['id'] === $grNode['id']) { + $isDuplicate = true; + break; + } + } + if (!$isDuplicate) { + $rootNode['children'][] = $grNode; + } + } + } + } + + // 補充廠商名稱 (跨模組) + $this->hydrateVendorNames($rootNode); + + return $rootNode; + } + + /** + * 建立原料的逆向溯源節點 + */ + private function buildMaterialBackwardNode(Inventory $inventory, $poItem = null): array + { + $node = [ + 'id' => 'inv_' . $inventory->id, + 'type' => 'material_batch', + 'label' => '原料批號: ' . $inventory->batch_number, + 'product_name' => $inventory->product?->name, + 'spec' => $inventory->product?->spec, + 'batch_number' => $inventory->batch_number, + 'quantity' => $poItem ? $poItem->quantity_used : null, + 'warehouse_name' => $inventory->warehouse?->name, + 'children' => [] + ]; + + // 繼續往下追溯該原料是怎麼來的 (進貨單) + if ($inventory->batch_number) { + $grItems = GoodsReceiptItem::with(['goodsReceipt', 'product']) + ->where('batch_number', $inventory->batch_number) + ->where('product_id', $inventory->product_id) + ->get(); + + foreach ($grItems as $grItem) { + $gr = $grItem->goodsReceipt; + if ($gr) { + $node['children'][] = [ + 'id' => 'gr_' . $gr->id, + 'type' => 'goods_receipt', + 'label' => '進貨單: ' . $gr->code, + 'date' => $gr->received_date instanceof \DateTimeInterface + ? $gr->received_date->format('Y-m-d') + : $gr->received_date, + 'vendor_id' => $gr->vendor_id, + 'quantity' => $grItem->quantity, + 'product_name' => $grItem->product?->name, + 'children' => [] + ]; + } + } + } + + return $node; + } + + /** + * 順向追蹤:從原料批號往後追查被用在哪些成品及去向 + * + * @param string $batchNumber 原料批號 + * @return array 樹狀結構資料 + */ + public function traceForward(string $batchNumber): array + { + $baseInventory = Inventory::with(['product', 'warehouse']) + ->where('batch_number', $batchNumber) + ->first(); + + $rootNode = [ + 'id' => 'batch_' . $batchNumber, + 'type' => 'source_batch', + 'label' => '查詢批號: ' . $batchNumber, + 'batch_number' => $batchNumber, + 'product_name' => $baseInventory?->product?->name, + 'spec' => $baseInventory?->product?->spec, + 'warehouse_name' => $baseInventory?->warehouse?->name, + 'children' => [] + ]; + + // 1. 尋找這個批號被哪些工單使用了 + $inventories = Inventory::with(['product', 'warehouse'])->where('batch_number', $batchNumber)->get(); + + foreach ($inventories as $inv) { + // 透過 ProductionService 獲取,以落實模組解耦 + $poItems = $this->productionService->getProductionOrderItemsByInventoryId($inv->id, ['productionOrder']); + + foreach ($poItems as $item) { + $po = $item->productionOrder; + if ($po) { + $poNode = [ + 'id' => 'po_' . $po->id, + 'type' => 'production_order', + 'label' => '投入工單: ' . $po->code, + 'date' => $po->production_date instanceof \DateTimeInterface + ? $po->production_date->format('Y-m-d') + : $po->production_date, + 'quantity' => $item->quantity_used, + 'children' => [] + ]; + + // 該工單產出的成品批號 + if ($po->output_batch_number) { + $outputInventory = Inventory::with(['product', 'warehouse']) + ->where('batch_number', $po->output_batch_number) + ->first(); + + $outputNode = [ + 'id' => 'output_batch_' . $po->output_batch_number, + 'type' => 'target_batch', + 'label' => '產出成品: ' . $po->output_batch_number, + 'batch_number' => $po->output_batch_number, + 'quantity' => $po->output_quantity, + 'product_name' => $outputInventory?->product?->name, + 'spec' => $outputInventory?->product?->spec, + 'warehouse_name' => $outputInventory?->warehouse?->name, + 'children' => [] + ]; + + // 追蹤成品的出庫紀錄 (銷貨、領料等) + $outTransactions = InventoryTransaction::with(['reference', 'inventory.product']) + ->whereHas('inventory', function ($q) use ($po) { + $q->where('batch_number', $po->output_batch_number); + }) + ->where('quantity', '<', 0) // 出庫 + ->get(); + + foreach ($outTransactions as $txn) { + $refType = class_basename($txn->reference_type); + $outputNode['children'][] = [ + 'id' => 'txn_' . $txn->id, + 'type' => 'outbound_transaction', + 'label' => '出庫單據: ' . $refType . ' #' . $txn->reference_id, + 'date' => $txn->actual_time, + 'quantity' => abs($txn->quantity), + 'product_name' => $txn->inventory?->product?->name, + 'children' => [] + ]; + } + + $poNode['children'][] = $outputNode; + } + + $rootNode['children'][] = $poNode; + } + } + } + + // 2. 如果這個批號自己本身就有出庫紀錄 (不是被生產掉,而是直接被領走或賣掉) + foreach ($inventories as $inv) { + $outTransactions = InventoryTransaction::with(['reference', 'inventory.product']) + ->where('inventory_id', $inv->id) + ->where('quantity', '<', 0) + ->get(); + + foreach ($outTransactions as $txn) { + // 如果是生產工單領料,上面已經處理過,這裡濾掉 + if ($txn->reference_type && str_contains($txn->reference_type, 'ProductionOrder')) { + continue; + } + + $refType = $txn->reference_type ? class_basename($txn->reference_type) : '未知'; + $rootNode['children'][] = [ + 'id' => 'txn_direct_' . $txn->id, + 'type' => 'outbound_transaction', + 'label' => '直接出庫: ' . $refType . ' #' . $txn->reference_id, + 'date' => $txn->actual_time, + 'quantity' => abs($txn->quantity), + 'product_name' => $txn->inventory?->product?->name, + 'children' => [] + ]; + } + } + + return $rootNode; + } + + /** + * 水和廠商名稱 (跨模組) + */ + private function hydrateVendorNames(array &$node): void + { + $vendorIds = []; + $this->collectVendorIds($node, $vendorIds); + + if (empty($vendorIds)) return; + + $vendors = $this->procurementService->getVendorsByIds(array_unique($vendorIds))->keyBy('id'); + + $this->applyVendorNames($node, $vendors); + } + + private function collectVendorIds(array $node, array &$ids): void + { + if (isset($node['vendor_id'])) { + $ids[] = $node['vendor_id']; + } + if (!empty($node['children'])) { + foreach ($node['children'] as $child) { + $this->collectVendorIds($child, $ids); + } + } + } + + private function applyVendorNames(array &$node, Collection $vendors): void + { + if (isset($node['vendor_id']) && $vendors->has($node['vendor_id'])) { + $vendor = $vendors->get($node['vendor_id']); + $node['label'] .= ' (廠商: ' . $vendor->name . ')'; + } + if (!empty($node['children'])) { + foreach ($node['children'] as &$child) { + $this->applyVendorNames($child, $vendors); + } + } + } +} diff --git a/app/Modules/Production/Contracts/ProductionServiceInterface.php b/app/Modules/Production/Contracts/ProductionServiceInterface.php index 4e21c25..4482ff9 100644 --- a/app/Modules/Production/Contracts/ProductionServiceInterface.php +++ b/app/Modules/Production/Contracts/ProductionServiceInterface.php @@ -5,4 +5,21 @@ namespace App\Modules\Production\Contracts; interface ProductionServiceInterface { public function getPendingProductionCount(): int; + + /** + * 尋找產出特定批號的生產工單 + * + * @param string $batchNumber + * @return \Illuminate\Support\Collection + */ + public function getProductionOrdersByOutputBatch(string $batchNumber): \Illuminate\Support\Collection; + + /** + * 尋找使用了特定庫存批號的生產工單項目 + * + * @param int $inventoryId + * @param array $with + * @return \Illuminate\Support\Collection + */ + public function getProductionOrderItemsByInventoryId(int $inventoryId, array $with = []): \Illuminate\Support\Collection; } diff --git a/app/Modules/Production/Models/ProductionOrderItem.php b/app/Modules/Production/Models/ProductionOrderItem.php index 92eaabd..a71e1e5 100644 --- a/app/Modules/Production/Models/ProductionOrderItem.php +++ b/app/Modules/Production/Models/ProductionOrderItem.php @@ -26,4 +26,9 @@ class ProductionOrderItem extends Model { return $this->belongsTo(ProductionOrder::class); } + + public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(\App\Modules\Inventory\Models\Inventory::class); + } } diff --git a/app/Modules/Production/Services/ProductionService.php b/app/Modules/Production/Services/ProductionService.php index 164338e..50413e5 100644 --- a/app/Modules/Production/Services/ProductionService.php +++ b/app/Modules/Production/Services/ProductionService.php @@ -4,6 +4,7 @@ namespace App\Modules\Production\Services; use App\Modules\Production\Contracts\ProductionServiceInterface; use App\Modules\Production\Models\ProductionOrder; +use App\Modules\Production\Models\ProductionOrderItem; class ProductionService implements ProductionServiceInterface { @@ -11,4 +12,18 @@ class ProductionService implements ProductionServiceInterface { return ProductionOrder::where('status', 'pending')->count(); } + + public function getProductionOrdersByOutputBatch(string $batchNumber): \Illuminate\Support\Collection + { + return ProductionOrder::with(['items.inventory.product', 'items.inventory']) + ->where('output_batch_number', $batchNumber) + ->get(); + } + + public function getProductionOrderItemsByInventoryId(int $inventoryId, array $with = []): \Illuminate\Support\Collection + { + return ProductionOrderItem::with($with) + ->where('inventory_id', $inventoryId) + ->get(); + } } diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index ea50e80..16276c1 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -70,6 +70,9 @@ class PermissionSeeder extends Seeder 'inventory_report.view' => '檢視', 'inventory_report.export' => '匯出', + // 批號溯源 + 'inventory_traceability.view' => '檢視', + // 進貨單管理 'goods_receipts.view' => '檢視', 'goods_receipts.create' => '建立', @@ -191,7 +194,7 @@ class PermissionSeeder extends Seeder 'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete', 'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete', 'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.dispatch', 'inventory_transfer.receive', - 'inventory_report.view', 'inventory_report.export', + 'inventory_report.view', 'inventory_report.export', 'inventory_traceability.view', 'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete', 'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.delete', 'production_orders.view', 'production_orders.create', 'production_orders.edit', 'production_orders.delete', @@ -216,7 +219,7 @@ class PermissionSeeder extends Seeder 'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete', 'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete', 'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.dispatch', 'inventory_transfer.receive', - 'inventory_report.view', 'inventory_report.export', + 'inventory_report.view', 'inventory_report.export', 'inventory_traceability.view', 'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete', 'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete', 'production_orders.view', 'production_orders.create', 'production_orders.edit', @@ -246,6 +249,7 @@ class PermissionSeeder extends Seeder 'warehouses.view', 'utility_fees.view', 'inventory_report.view', + 'inventory_traceability.view', 'accounting.view', 'account_payables.view', 'sales_orders.view', diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index d387bf1..a9729ba 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -278,6 +278,13 @@ export default function AuthenticatedLayout({ route: "/inventory/analysis", permission: "inventory_report.view", }, + { + id: "inventory-traceability", + label: "批號溯源", + icon: , + route: "/inventory/traceability", + permission: "inventory_traceability.view", + }, ], }, { diff --git a/resources/js/Pages/Inventory/Traceability/Components/TraceabilitySummary.tsx b/resources/js/Pages/Inventory/Traceability/Components/TraceabilitySummary.tsx new file mode 100644 index 0000000..c8bd1cf --- /dev/null +++ b/resources/js/Pages/Inventory/Traceability/Components/TraceabilitySummary.tsx @@ -0,0 +1,150 @@ +import { Info } from "lucide-react"; +import { TraceabilityNode } from "./TreeView"; + +interface TraceabilitySummaryProps { + data: TraceabilityNode; + direction: 'forward' | 'backward'; +} + +export function TraceabilitySummary({ data, direction }: TraceabilitySummaryProps) { + if (!data) return null; + + // --- Helper to extract unqiue names/codes from children --- + const getUniqueLabels = (nodes: TraceabilityNode[], prefixToStrip: string = '') => { + const labels = nodes + .map(n => n.label.replace(prefixToStrip, '').trim()) + .filter(Boolean); + return Array.from(new Set(labels)); + }; + + // --- Logic for Backward Tracing --- + if (direction === 'backward') { + const poNodes = data.children?.filter(c => c.type === 'production_order') || []; + const poNames = getUniqueLabels(poNodes, '生產工單:'); + + let materialNodes: TraceabilityNode[] = []; + let grNodes: TraceabilityNode[] = []; + + poNodes.forEach(po => { + const materials = po.children?.filter(c => c.type === 'material_batch') || []; + materialNodes = [...materialNodes, ...materials]; + + materials.forEach(mat => { + const grs = mat.children?.filter(c => c.type === 'goods_receipt') || []; + grNodes = [...grNodes, ...grs]; + }); + }); + + const materialNames = getUniqueLabels(materialNodes, '原料批號:'); + const grNames = getUniqueLabels(grNodes, '進貨單:'); + + // Handle case where batch is directly from Goods Receipt (no PO) + const directGrNodes = data.children?.filter(c => c.type === 'goods_receipt') || []; + if (directGrNodes.length > 0) { + grNodes = [...grNodes, ...directGrNodes]; + const directGrNames = getUniqueLabels(directGrNodes, '進貨單:'); + + return ( +
+ +
+

+ 查詢的批號 {data.batch_number} + {data.product_name && ({data.product_name})} + 主要是由進貨單 {directGrNames.map(n => {n}).reduce((prev, curr) => [prev, '、', curr] as any)} 採購進貨。 +

+
+
+ ); + } + + return ( +
+ +
+

+ 查詢的成品批號 {data.batch_number} + {data.product_name && ({data.product_name})} 是由 + {poNames.length > 0 ? ( + <> + 生產工單 {poNames.slice(0, 3).map(n => {n}).reduce((prev, curr) => [prev, '、', curr] as any)} + {poNames.length > 3 && ` 等 ${poNames.length} 張工單`} 產出。 + + ) : ( + '未知的生產工單產出。' + )} +

+ {materialNodes.length > 0 && ( +

+ 這些工單總共使用了 {materialNodes.length} 批原料 + (包含原料批號 {materialNames.slice(0, 3).map(n => {n}).reduce((prev, curr) => [prev, '、', curr] as any)} + {materialNames.length > 3 && ' 等'})。 +

+ )} + {grNodes.length > 0 && ( +

+ 而這些原料主要來自於進貨單 {grNames.slice(0, 3).map(n => {n}).reduce((prev, curr) => [prev, '、', curr] as any)} + {grNames.length > 3 && ` 等 ${grNames.length} 張單據`}。 +

+ )} +
+
+ ); + } + + // --- Logic for Forward Tracing --- + if (direction === 'forward') { + const poNodes = data.children?.filter(c => c.type === 'production_order') || []; + const poNames = getUniqueLabels(poNodes, '投入工單:'); + + let targetNodes: TraceabilityNode[] = []; + let outNodes: TraceabilityNode[] = []; + + poNodes.forEach(po => { + const targets = po.children?.filter(c => c.type === 'target_batch') || []; + targetNodes = [...targetNodes, ...targets]; + + targets.forEach(tgt => { + const outs = tgt.children?.filter(c => c.type === 'outbound_transaction') || []; + outNodes = [...outNodes, ...outs]; + }); + }); + + const targetNames = getUniqueLabels(targetNodes, '產出成品:'); + const outNames = getUniqueLabels(outNodes, '出庫單據:'); + + return ( +
+ +
+

+ 查詢的原料批號 {data.batch_number} + {data.product_name && ({data.product_name})} 被投入了 + {poNames.length > 0 ? ( + <> + 生產工單 {poNames.slice(0, 3).map(n => {n}).reduce((prev, curr) => [prev, '、', curr] as any)} + {poNames.length > 3 && ` 等 ${poNames.length} 張工單`}。 + + ) : ( + ' 未知的生產工單。' + )} +

+ {targetNodes.length > 0 && ( +

+ 這些工單隨後產出了成品批號 {targetNames.slice(0, 3).map(n => {n}).reduce((prev, curr) => [prev, '、', curr] as any)} + {targetNames.length > 3 && ` 等 ${targetNodes.length} 批成品`}。 +

+ )} + {outNodes.length > 0 && ( +

+ 最終,這些成品透過 {outNames.slice(0, 3).map(n => {n}).reduce((prev, curr) => [prev, '、', curr] as any)} + {outNames.length > 3 && ` 等 ${outNodes.length} 張單據`} 離開了倉庫。 +

+ )} +
+
+ ); + } + + return null; +} diff --git a/resources/js/Pages/Inventory/Traceability/Components/TreeView.tsx b/resources/js/Pages/Inventory/Traceability/Components/TreeView.tsx new file mode 100644 index 0000000..586d9a2 --- /dev/null +++ b/resources/js/Pages/Inventory/Traceability/Components/TreeView.tsx @@ -0,0 +1,168 @@ +import { cn } from '@/lib/utils'; +import { Package, Truck, Factory, CornerDownRight, Box, ShoppingCart } from 'lucide-react'; +import { formatDate } from '@/lib/date'; + +export interface TraceabilityNode { + id: string; + type: 'target_batch' | 'source_batch' | 'production_order' | 'material_batch' | 'goods_receipt' | 'outbound_transaction'; + label: string; + batch_number?: string; + date?: string; + vendor_id?: number | string; + product_name?: string; + spec?: string; + quantity?: number | string; + unit?: string; + warehouse_name?: string; + children?: TraceabilityNode[]; +} + +interface TreeViewProps { + data: TraceabilityNode; +} + +const getNodeIcon = (type: TraceabilityNode['type']) => { + switch (type) { + case 'target_batch': + case 'source_batch': + return ; + case 'production_order': + return ; + case 'material_batch': + return ; + case 'goods_receipt': + return ; + case 'outbound_transaction': + return ; + default: + return ; + } +}; + +const getNodeColor = (type: TraceabilityNode['type']) => { + switch (type) { + case 'target_batch': + case 'source_batch': + return 'border-primary-light bg-primary-lightest'; + case 'production_order': + return 'border-orange-200 bg-orange-50'; + case 'material_batch': + return 'border-primary-light bg-primary-lightest'; + case 'goods_receipt': + return 'border-blue-200 bg-blue-50'; + case 'outbound_transaction': + return 'border-emerald-200 bg-emerald-50'; + default: + return 'border-gray-200 bg-gray-50'; + } +}; + +const TreeNode = ({ node, isLast, level = 0 }: { node: TraceabilityNode; isLast: boolean; level?: number }) => { + const hasChildren = node.children && node.children.length > 0; + + return ( +
+ {/* 連接線 (上層到此節點) */} + {level > 0 && ( +
+ )} + + {/* 橫向連接線 */} + {level > 0 && ( +
+ )} + +
+ {level > 0 && ( +
+ +
+ )} + +
+
+
+ {getNodeIcon(node.type)} +
+
+

{node.label}

+ {node.date && ( +
+ {formatDate(node.date)} +
+ )} +
+
+ + {(node.product_name || node.warehouse_name || (node.quantity !== undefined && node.quantity !== null)) && ( +
+ {node.product_name && ( +
+ 品名 / 規格 + + {node.product_name} + {node.spec && ({node.spec})} + +
+ )} + + {node.warehouse_name && ( +
+ 倉庫 + + {node.warehouse_name} + +
+ )} + + {node.quantity !== undefined && node.quantity !== null && ( +
+ 數量 + + {Number(node.quantity).toLocaleString()} + {node.unit || 'PCS'} + +
+ )} +
+ )} +
+
+ + {hasChildren && ( +
+ {node.children!.map((child, index) => ( + + ))} +
+ )} +
+ ); +}; + +export default function TreeView({ data }: TreeViewProps) { + if (!data) return null; + + return ( +
+ +
+ ); +} diff --git a/resources/js/Pages/Inventory/Traceability/Index.tsx b/resources/js/Pages/Inventory/Traceability/Index.tsx new file mode 100644 index 0000000..3dc864d --- /dev/null +++ b/resources/js/Pages/Inventory/Traceability/Index.tsx @@ -0,0 +1,154 @@ +import React, { useState } from 'react'; +import { Head, router } from '@inertiajs/react'; +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { PageProps } from '@/types/global'; +import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card'; +import { Button } from '@/Components/ui/button'; +import { Input } from '@/Components/ui/input'; +import { Label } from '@/Components/ui/label'; +import { TrendingUp, Search, RotateCcw } from 'lucide-react'; +import { RadioGroup, RadioGroupItem } from '@/Components/ui/radio-group'; +import { cn } from '@/lib/utils'; +import TreeView, { TraceabilityNode } from './Components/TreeView'; +import { TraceabilitySummary } from './Components/TraceabilitySummary'; +import { Can } from '@/Components/Permission/Can'; + +interface Props extends PageProps { + search: { + batch_number: string | null; + direction: 'backward' | 'forward'; + }; + result: TraceabilityNode | null; +} + +export default function TraceabilityIndex({ search, result }: Props) { + const [batchNumber, setBatchNumber] = useState(search.batch_number || ''); + const [direction, setDirection] = useState<'backward' | 'forward'>(search.direction || 'backward'); + const [isSearching, setIsSearching] = useState(false); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (!batchNumber.trim()) return; + + setIsSearching(true); + router.get( + route('inventory.traceability.index'), + { batch_number: batchNumber.trim(), direction }, + { + preserveState: true, + preserveScroll: true, + onFinish: () => setIsSearching(false) + } + ); + }; + + return ( + + + +
+
+
+

+ + 批號溯源 +

+

+ 透過批號追蹤產品的生產履歷,支援從成品追溯原料供應商(逆向),或從原料追查銷售去向(順向)。 +

+
+
+ + + + + + + 查詢條件 + + + +
+
+ + setBatchNumber(e.target.value)} + className="max-w-md w-full" + /> +
+ +
+ + setDirection(val)} + className="flex space-x-6" + > +
+ + +
+
+ + +
+
+
+ + +
+
+
+ + {search.batch_number && ( +
+ {result ? ( + <> + + + + ) : ( +
+ +

找不到符合的批號資料

+

請確認您輸入的批號「{search.batch_number}」是否正確存在於系統中。

+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/resources/js/Pages/Inventory/Transfer/Show.tsx b/resources/js/Pages/Inventory/Transfer/Show.tsx index 526d158..dc140cd 100644 --- a/resources/js/Pages/Inventory/Transfer/Show.tsx +++ b/resources/js/Pages/Inventory/Transfer/Show.tsx @@ -13,14 +13,8 @@ import { TableRow, } from "@/Components/ui/table"; import { StatusBadge } from "@/Components/shared/StatusBadge"; -import { Checkbox } from "@/Components/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/Components/ui/dialog"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; + import { AlertDialog, AlertDialogAction, @@ -39,7 +33,7 @@ import { SelectTrigger, SelectValue, } from "@/Components/ui/select"; -import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Search, Truck, PackageCheck } from "lucide-react"; +import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Truck, PackageCheck } from "lucide-react"; import { toast } from "sonner"; import axios from "axios"; import { Can } from '@/Components/Permission/Can'; @@ -115,20 +109,20 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr } }, [order]); + const canEdit = can('inventory_transfer.edit'); + const isReadOnly = (order.status !== 'draft' || !canEdit); + const isItemsReadOnly = isReadOnly || !!order.requisition; + const isVending = order.to_warehouse_type === 'vending'; + // Product Selection - const [isProductDialogOpen, setIsProductDialogOpen] = useState(false); const [availableInventory, setAvailableInventory] = useState([]); const [loadingInventory, setLoadingInventory] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedInventory, setSelectedInventory] = useState([]); // product_id-batch useEffect(() => { - if (isProductDialogOpen) { + if (!isItemsReadOnly && order.from_warehouse_id) { loadInventory(); - setSelectedInventory([]); - setSearchQuery(''); } - }, [isProductDialogOpen]); + }, [isItemsReadOnly, order.from_warehouse_id]); const loadInventory = async () => { setLoadingInventory(true); @@ -143,57 +137,22 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr } }; - const toggleSelect = (key: string) => { - setSelectedInventory(prev => - prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] - ); - }; - - const toggleSelectAll = () => { - const filtered = availableInventory.filter(inv => - inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) || - inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) || - (inv.product_barcode && inv.product_barcode.toLowerCase().includes(searchQuery.toLowerCase())) - ); - const filteredKeys = filtered.map(inv => `${inv.product_id}-${inv.batch_number}`); - - if (filteredKeys.length > 0 && filteredKeys.every(k => selectedInventory.includes(k))) { - setSelectedInventory(prev => prev.filter(k => !filteredKeys.includes(k))); - } else { - setSelectedInventory(prev => Array.from(new Set([...prev, ...filteredKeys]))); - } - }; - - const handleAddSelected = () => { - if (selectedInventory.length === 0) return; - - const newItems = [...items]; - let addedCount = 0; - - availableInventory.forEach(inv => { - const key = `${inv.product_id}-${inv.batch_number}`; - if (selectedInventory.includes(key)) { - newItems.push({ - product_id: inv.product_id, - product_name: inv.product_name, - product_code: inv.product_code, - batch_number: inv.batch_number, - expiry_date: inv.expiry_date, - unit: inv.unit_name, - quantity: 1, - max_quantity: inv.quantity, - notes: "", - }); - addedCount++; + const handleAddItem = () => { + setItems([ + ...items, + { + product_id: "", + product_name: "", + product_code: "", + batch_number: "", + expiry_date: null, + unit: "", + quantity: "", + max_quantity: 0, + position: "", + notes: "" } - }); - - setItems(newItems); - setIsProductDialogOpen(false); - - if (addedCount > 0) { - toast.success(`已成功加入 ${addedCount} 個項目`); - } + ]); }; const handleUpdateItem = (index: number, field: string, value: any) => { @@ -210,11 +169,16 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr const handleSave = async () => { setIsSaving(true); try { - await router.put(route('inventory.transfer.update', [order.id]), { - items: items, + const payload: any = { remarks: remarks, transit_warehouse_id: transitWarehouseId || '', - }, { + }; + + if (!order.requisition) { + payload.items = items; + } + + await router.put(route('inventory.transfer.update', [order.id]), payload, { onSuccess: () => { }, onError: () => toast.error("儲存失敗,請檢查輸入"), }); @@ -223,15 +187,19 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr } }; - // 確認出貨 / 確認過帳(無在途倉) // 確認出貨 / 確認過帳(無在途倉) const handlePost = () => { - router.put(route('inventory.transfer.update', [order.id]), { + const payload: any = { action: 'post', transit_warehouse_id: transitWarehouseId || '', - items: items, remarks: remarks, - }, { + }; + + if (!order.requisition) { + payload.items = items; + } + + router.put(route('inventory.transfer.update', [order.id]), payload, { onSuccess: () => { setIsPostDialogOpen(false); }, @@ -267,10 +235,7 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr }); }; - const canEdit = can('inventory_transfer.edit'); - const isReadOnly = (order.status !== 'draft' || !canEdit); - const isItemsReadOnly = isReadOnly || !!order.requisition; - const isVending = order.to_warehouse_type === 'vending'; + // 狀態 Badge 渲染 const renderStatusBadge = () => { @@ -579,146 +544,10 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr onOpenChange={setIsImportDialogOpen} orderId={order.id} /> - - - - - - - - 選擇來源庫存 ({order.from_warehouse_name}) -
- - setSearchQuery(e.target.value)} - /> -
-
-
- {loadingInventory ? ( -
- -

庫存資料載入中...

-
- ) : ( -
- - - - - 0 && (() => { - const filtered = availableInventory.filter(inv => - inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) || - inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) || - (inv.product_barcode && inv.product_barcode.toLowerCase().includes(searchQuery.toLowerCase())) - ); - const filteredKeys = filtered.map(inv => `${inv.product_id}-${inv.batch_number}`); - return filteredKeys.length > 0 && filteredKeys.every(k => selectedInventory.includes(k)); - })()} - onCheckedChange={() => toggleSelectAll()} - /> - - - 品名 / 代號 - 批號 - 效期 - 現有庫存 - - - - {(() => { - const filtered = availableInventory.filter(inv => - inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) || - inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) || - (inv.product_barcode && inv.product_barcode.toLowerCase().includes(searchQuery.toLowerCase())) - ); - - if (filtered.length === 0) { - return ( - - - {searchQuery ? `找不到與 "${searchQuery}" 相關的商品` : '尚無庫存資料'} - - - ); - } - - return filtered.map((inv) => { - const key = `${inv.product_id}-${inv.batch_number}`; - const isSelected = selectedInventory.includes(key); - return ( - toggleSelect(key)} - > - e.stopPropagation()}> - toggleSelect(key)} - /> - - - -
- {inv.product_name} - {inv.product_code} -
-
- {inv.batch_number || '-'} - {inv.expiry_date || '-'} - {inv.quantity} {inv.unit_name} -
- ); - }); - })()} -
-
-
- )} -
-
-
-
- 已選取 {selectedInventory.length} 項商品 -
- {selectedInventory.length > 0 && ( - - )} -
-
- - -
-
-
-
+
)}
@@ -752,10 +581,41 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr {index + 1} -
- {item.product_name} - {item.product_code} -
+ {isItemsReadOnly || item.product_id ? ( +
+ {item.product_name} + {item.product_code} +
+ ) : ( + { + const [pid, batch] = val.split('|'); + const inv = availableInventory.find(i => String(i.product_id) === pid && (i.batch_number || '') === batch); + if (inv) { + const newItems = [...items]; + newItems[index] = { + ...newItems[index], + product_id: inv.product_id, + product_name: inv.product_name, + product_code: inv.product_code, + batch_number: inv.batch_number, + expiry_date: inv.expiry_date, + unit: inv.unit_name, + max_quantity: inv.quantity, + quantity: newItems[index].quantity || 1 + }; + setItems(newItems); + } + }} + options={availableInventory.map(inv => ({ + label: `${inv.product_code} - ${inv.product_name} ${inv.batch_number ? `(批號: ${inv.batch_number})` : ''} - 庫存: ${inv.quantity}`, + value: `${inv.product_id}|${inv.batch_number || ''}` + }))} + placeholder={loadingInventory ? "載入庫存中..." : "搜尋名稱或代號選擇庫存"} + className="w-full min-w-[200px]" + /> + )}
{item.batch_number || '-'}
@@ -766,7 +626,7 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr )}
- {item.max_quantity} {item.unit || item.unit_name} + {item.product_id ? `${item.max_quantity} ${item.unit || item.unit_name || ''}` : '-'} {isItemsReadOnly ? ( diff --git a/resources/js/Pages/StoreRequisition/Show.tsx b/resources/js/Pages/StoreRequisition/Show.tsx index 917f7e6..498eecd 100644 --- a/resources/js/Pages/StoreRequisition/Show.tsx +++ b/resources/js/Pages/StoreRequisition/Show.tsx @@ -175,15 +175,33 @@ export default function Show({ requisition, warehouses }: Props) { }; const handleApprove = () => { - // 確認每個核准數量 + // 確認每個核准數量與庫存上限 for (const item of approvedItems) { + const originalItem = requisition.items.find(i => i.id === item.id); + if (!originalItem) continue; + for (const batch of item.batches) { if (batch.qty !== "") { const qty = parseFloat(batch.qty); if (isNaN(qty) || qty < 0) { - toast.error("核准數量不能為負數"); + toast.error("核准數量不能為負數或無效數字"); return; } + + // 檢查是否超過批號最大可用庫存 + if (batch.inventory_id && originalItem.supply_batches) { + const originalBatch = originalItem.supply_batches.find(b => b.inventory_id === batch.inventory_id); + if (originalBatch && qty > originalBatch.available_qty) { + toast.error(`「${originalItem.product_name}」批號 ${originalBatch.batch_number || '無批號'} 數量不可大於庫存上限 (${originalBatch.available_qty})`); + return; + } + } else if (batch.inventory_id === null) { + // 無批號情境:檢查總可用庫存 + if (originalItem.supply_stock !== null && qty > originalItem.supply_stock) { + toast.error(`「${originalItem.product_name}」數量不可大於供貨倉庫存上限 (${originalItem.supply_stock})`); + return; + } + } } } }