feat(Inventory): 實作批號溯源完整功能與 UI 呈現,包含文字敘述卡片與更完整的關聯屬性

This commit is contained in:
2026-02-26 10:39:24 +08:00
parent 63e4f88a14
commit f960aaaeb2
16 changed files with 1085 additions and 694 deletions

View File

@@ -7,506 +7,93 @@ name: 客戶端後台 UI 統一規範
description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為 description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為
--- ---
## 概述 ## 適用範圍
技能提供 Star ERP 系統**客戶端(租戶端後台**的 UI 統一性規範,確保所有頁面使用一致的元件、樣式類別、圖標和佈局模式 規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`
> **適用範圍**:本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。 ## 核心禁止事項
## 核心原則 - ❌ **禁止 Hardcode 色碼**(如 `text-[#01ab83]`),必須使用 `*-primary-main` 等 Tailwind Class 或 CSS 變數
- ❌ **禁止使用非 `lucide-react` 的圖標庫**(如 FontAwesome、Material Icons
1. **使用統一的 UI 組件庫**:優先使用 `@/Components/ui/` 中的 47 個元件 - ❌ **禁止操作按鈕不包裹 `<Can>` 權限元件**
2. **遵循既定的樣式類別**:使用 `app.css` 中定義的自定義按鈕類別
3. **統一的圖標系統**:全面使用 `lucide-react` 圖標
4. **一致的佈局模式**:表格、分頁、操作按鈕等保持相同結構
5. **權限控制**:所有操作按鈕必須使用 `<Can>` 元件包裹
--- ---
## 1. 專案結構 ## 1. 色彩系統
### 1.1 關鍵目錄 ### 主題色(動態租戶品牌色,由 `AuthenticatedLayout` 自動注入)
``` | Tailwind Class | 用途 |
resources/ |---|---|
├── css/ | `*-primary-main` | 主色:按鈕、連結、強調 |
│ └── app.css # 全域樣式與設計 Token | `*-primary-dark` | Hover 狀態 |
├── js/ | `*-primary-light` | 次要強調 |
│ ├── Components/ | `*-primary-lightest` | 背景底色、Active 狀態 |
│ │ ├── ui/ # 47 個基礎 UI 元件 (shadcn/ui)
│ │ ├── shared/ # 共用業務元件 (Pagination, BreadcrumbNav 等)
│ │ └── Permission/ # 權限控制元件 (Can, HasRole, CanAll)
│ ├── Layouts/
│ │ ├── AuthenticatedLayout.tsx # 客戶端後台佈局 ⬅️ 本規範適用
│ │ └── LandlordLayout.tsx # 中央管理後台佈局
│ └── Pages/ # 頁面元件
```
### 1.2 可用 UI 元件清單 ### 灰階與狀態色
``` 直接參考 `resources/css/app.css` 中定義的 `--grey-0` ~ `--grey-5``--other-success/error/warning/info` 變數。
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
```
--- ---
## 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 | 說明 | **尺寸**:表格操作列用 `size="sm"`,一般操作用 `size="default"`,主要 CTA 用 `size="lg"`
|----------------|--------------|------|
| `*-primary-main` | `--primary-main` | **主色**:與租戶設定一致(預設綠色),用於主要按鈕、連結、強調文字 |
| `*-primary-dark` | `--primary-dark` | **深色**:系統自動計算,用於 Hover 狀態 |
| `*-primary-light` | `--primary-light` | **淺色**:系統自動計算,用於次要強調 |
| `*-primary-lightest` | `--primary-lightest` | **最淺色**系統自動計算用於背景底色、Active 狀態 |
**運作機制** **返回按鈕**:放置於標題上方,使用 `variant="outline"` + `className="gap-2 button-outlined-primary"`,搭配 `<ArrowLeft />` 圖標。
`AuthenticatedLayout` 會根據後端回傳的 `branding` 資料,自動注入 CSS 變數覆寫預設值。
```tsx
// ✅ 正確:使用 Tailwind Class
<div className="text-primary-main">...</div>
// ✅ 正確:使用 CSS 變數 (自定義樣式時)
<div style={{ borderColor: 'var(--primary-main)' }}>...</div>
// ❌ 錯誤:寫死色碼 (會導致租戶無法換色)
<div className="text-[#01ab83]">...</div>
```
### 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; /* 資訊 - 說明、提示 */
```
--- ---
## 3. 按鈕規範 ## 3. 圖標規範
### 3.1 按鈕樣式類別 統一使用 `lucide-react`
專案在 `resources/css/app.css` 中定義了統一的按鈕樣式,**必須**使用這些類別: | 尺寸 | 用途 |
|---|---|
| `h-4 w-4` | 按鈕內、表格操作 |
| `h-5 w-5` | 側邊欄選單 |
| `h-6 w-6` | 頁面標題 |
#### Filled 按鈕(實心按鈕)— 用於主要操作 常用映射:`Plus`(新增)、`Pencil`(編輯)、`Trash2`(刪除)、`Eye`(檢視)、`Search`(搜尋)、`ArrowLeft`(返回)。
其餘請參考 `AuthenticatedLayout.tsx` 中的 `allMenuItems` 定義。
```tsx
// ✅ 主要操作按鈕(綠色主題色)- 新增、儲存、確認
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
新增項目
</Button>
// ✅ 成功操作
<Button className="button-filled-success">確認</Button>
// ✅ 資訊操作(用於系統提示、說明等非業務主流程)
<Button className="button-filled-info">系統資訊</Button>
// ✅ 警告操作
<Button className="button-filled-warning">警告</Button>
// ✅ 錯誤/刪除操作AlertDialog 內確認按鈕)
<Button className="button-filled-error">刪除</Button>
```
#### Outlined 按鈕(邊框按鈕)— 用於次要操作
```tsx
// ✅ 編輯按鈕(表格操作列)
<Button variant="outline" size="sm" className="button-outlined-primary">
<Pencil className="h-4 w-4" />
</Button>
// ✅ 刪除按鈕(表格操作列)
<Button variant="outline" size="sm" className="button-outlined-error">
<Trash2 className="h-4 w-4" />
</Button>
```
#### Text 按鈕(文字按鈕)
```tsx
<Button className="button-text-primary">查看更多</Button>
```
### 3.2 按鈕大小
| Size | 高度 | 使用情境 |
|------|------|----------|
| `size="sm"` | h-8 | 表格操作列、緊湊佈局 |
| `size="default"` | h-9 | 一般操作、表單提交 |
| `size="lg"` | h-10 | 主要 CTA、頁面主操作 |
| `size="icon"` | 9×9 | 純圖標按鈕 |
### 3.3 常見操作按鈕模式
#### 頁面頂部新增按鈕
```tsx
<Can permission="resource.create">
<Link href={route('resource.create')}>
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
新增XXX
</Button>
</Link>
</Can>
```
#### 表格操作列檢視按鈕
```tsx
<Can permission="resource.view">
<Link href={route('resource.show', item.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="檢視"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
</Can>
```
#### 表格操作列編輯按鈕
```tsx
<Can permission="resource.edit">
<Link href={route('resource.edit', item.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
</Can>
```
#### 表格操作列刪除按鈕(帶確認對話框)
```tsx
<Can permission="resource.delete">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>確認刪除</AlertDialogTitle>
<AlertDialogDescription>
確定要刪除「{item.name}」嗎?此操作無法復原。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(item.id)}
className="bg-red-600 hover:bg-red-700"
>
刪除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
```
### 3.4 返回按鈕規範
詳情頁面(如:查看庫存、進貨單詳情)的返回按鈕應統一放置於 **頁面標題上方**,並採用「**圖標 + 文字**」的 Outlined 樣式。
**樣式規格**
- **位置**:標題區域上方 (`mb-6`),獨立於標題列
- **樣式**`variant="outline"` + `className="gap-2 button-outlined-primary"`
- **圖標**`<ArrowLeft className="h-4 w-4" />`
- **文字**:清楚說明返回目的地,例如「返回倉庫管理」、「返回列表」
```tsx
<div className="mb-6">
<Link href={route('resource.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary"
>
<ArrowLeft className="h-4 w-4" />
返回列表
</Button>
</Link>
</div>
```
--- ---
## 3.5 頁面佈局規範(新增/編輯頁面) ## 4. 頁面佈局規範
### 標準結構 所有頁面遵循以下結構,參考範例:`Pages/Product/Create.tsx``Pages/PurchaseOrder/Create.tsx`
新增/編輯頁面(如:商品新增、採購單建立)應遵循以下標準結構 **關鍵規則**
- **外層容器**`className="container mx-auto p-6 max-w-7xl"`
```tsx - **標題樣式**`text-2xl font-bold text-grey-0 flex items-center gap-2`
<AuthenticatedLayout breadcrumbs={...}> - **說明文字**`text-gray-500 mt-1`
<Head title="..." /> - **麵包屑**:使用 `BreadcrumbItemType`(屬性為 `label`, `href`, `isPage`),不需要包含「首頁」
- **Input 元件**:不額外設定 focus 樣式,直接使用 `@/Components/ui/input` 的內建樣式
<div className="container mx-auto p-6 max-w-7xl"> - **日期顯示**:使用 `resources/js/lib/date.ts``formatDate` 工具
{/* Header */}
<div className="mb-6">
{/* 返回按鈕 */}
<Link href={route('resource.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-4"
>
<ArrowLeft className="h-4 w-4" />
返回列表
</Button>
</Link>
{/* 頁面標題區塊 */}
<div className="mb-4">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Icon className="h-6 w-6 text-primary-main" />
頁面標題
</h1>
<p className="text-gray-500 mt-1">
頁面說明文字
</p>
</div>
</div>
{/* 表單或內容區塊 */}
<FormComponent ... />
</div>
</AuthenticatedLayout>
```
### 關鍵規範
1. **外層容器**:使用 `className="container mx-auto p-6 max-w-7xl"` 確保寬度與間距一致
2. **Header 包裹**:使用 `<div className="mb-6">` 包裹返回按鈕與標題區塊
3. **返回按鈕**:加上 `mb-4` 與標題區塊分隔
4. **標題區塊**:使用 `<div className="mb-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 常用操作圖標映射
| 操作 | 圖標組件 | 使用情境 |
|------|----------|----------|
| 新增 | `<Plus />` | 新增按鈕 |
| 編輯 | `<Pencil />` | 編輯按鈕 |
| 刪除 | `<Trash2 />` | 刪除按鈕 |
| 查看 | `<Eye />` | 查看詳情 |
| 搜尋 | `<Search />` | 搜尋欄位 |
| 篩選 | `<Filter />` | 篩選功能 |
| 下載 | `<Download />` | 下載/匯出 |
| 上傳 | `<Upload />` | 上傳/匯入 |
| 設定 | `<Settings />` | 設定功能 |
| 複製 | `<Copy />` | 複製內容 |
| 郵件 | `<Mail />` | Email 顯示 |
| 使用者 | `<Users />`, `<User />` | 使用者管理 |
| 權限 | `<Shield />` | 角色/權限 |
| 排序 | `<ArrowUpDown />`, `<ArrowUp />`, `<ArrowDown />` | 表格排序 |
| 儀表板 | `<LayoutDashboard />` | 首頁/總覽 |
| 商品 | `<Package />` | 商品管理 |
| 倉庫 | `<Warehouse />` | 倉庫管理 |
| 廠商 | `<Truck />`, `<Contact2 />` | 廠商管理 |
| 採購 | `<ShoppingCart />` | 採購管理 |
### 4.4 圖標使用範例
```tsx
import { Plus, Pencil, Trash2, Users } from 'lucide-react';
// 頁面標題
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Users className="h-6 w-6 text-[#01ab83]" />
使用者管理
</h1>
// 按鈕內圖標(圖標在左,帶文字)
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
新增使用者
</Button>
// 純圖標按鈕(表格操作列)
<Button variant="outline" size="sm" className="button-outlined-primary">
<Pencil className="h-4 w-4" />
</Button>
```
--- ---
## 5. 表格規範 ## 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 ### 排序(三態切換)
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
{/* 表格內容 */}
</Table>
</div>
```
### 5.2 表格標題列 - 未排序:`ArrowUpDown``text-muted-foreground`
- 升冪:`ArrowUp``text-primary`
```tsx - 降冪:`ArrowDown``text-primary`
<TableHeader className="bg-gray-50"> - 後端必須處理 `sort_by``sort_order` 參數
<TableRow> - 參考實作:`Pages/Product/Index.tsx``handleSort`
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead>名稱</TableHead>
<TableHead className="text-center">操作</TableHead>
</TableRow>
</TableHeader>
```
**關鍵要點**
- 使用 `bg-gray-50` 背景色
- 序號欄位固定寬度 `w-[50px]` 並置中
- 操作欄位置中顯示
### 5.3 表格主體
```tsx
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-gray-500">
無符合條件的資料
</TableCell>
</TableRow>
) : (
items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-gray-500 font-medium text-center">
{startIndex + index}
</TableCell>
{/* 其他欄位 */}
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
{/* 操作按鈕 */}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
```
**關鍵要點**
- 空狀態訊息使用置中、灰色文字
- 序號欄使用 `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 <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
}
if (filters.sort_order === "asc") {
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
}
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
};
// 2. 表格標題應用
<TableHead>
<button
onClick={() => handleSort('created_at')}
className="flex items-center hover:text-gray-900"
>
建立時間 <SortIcon field="created_at" />
</button>
</TableHead>
// 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

View File

@@ -185,6 +185,7 @@ class RoleController extends Controller
'inventory_adjust' => '庫存盤調管理', 'inventory_adjust' => '庫存盤調管理',
'inventory_transfer' => '庫存調撥管理', 'inventory_transfer' => '庫存調撥管理',
'inventory_report' => '庫存報表', 'inventory_report' => '庫存報表',
'inventory_traceability' => '批號溯源',
'vendors' => '廠商資料管理', 'vendors' => '廠商資料管理',
'purchase_orders' => '採購單管理', 'purchase_orders' => '採購單管理',
'purchase_returns' => '採購退回管理', 'purchase_returns' => '採購退回管理',

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\Inertia;
use App\Modules\Inventory\Services\TraceabilityService;
class TraceabilityController extends Controller
{
public function __construct(
protected TraceabilityService $traceabilityService
) {}
/**
* 顯示批號溯源查詢的主頁面
*/
public function index(Request $request)
{
$batchNumber = $request->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
]);
}
}

View File

@@ -38,6 +38,11 @@ Route::middleware('auth')->group(function () {
Route::get('/inventory/analysis', [InventoryAnalysisController::class, 'index'])->name('inventory.analysis.index'); 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::middleware('permission:products.view')->group(function () {
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index'); Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');

View File

@@ -122,8 +122,13 @@ class StoreRequisitionService
$processedItems = []; // 暫存處理後的明細,用於轉入調撥單 $processedItems = []; // 暫存處理後的明細,用於轉入調撥單
if (isset($data['items'])) { if (isset($data['items'])) {
$requisition->load('items.product');
$reqItemMap = $requisition->items->keyBy('id');
foreach ($data['items'] as $itemData) { foreach ($data['items'] as $itemData) {
$reqItemId = $itemData['id']; $reqItemId = $itemData['id'];
$reqItem = $reqItemMap->get($reqItemId);
$productName = $reqItem?->product?->name ?? '未知商品';
$totalApprovedQty = 0; $totalApprovedQty = 0;
$batches = $itemData['batches'] ?? []; $batches = $itemData['batches'] ?? [];
@@ -133,7 +138,22 @@ class StoreRequisitionService
foreach ($batches as $batch) { foreach ($batches as $batch) {
$qty = (float)($batch['qty'] ?? 0); $qty = (float)($batch['qty'] ?? 0);
$bNum = $batch['batch_number'] ?? null; $bNum = $batch['batch_number'] ?? null;
$invId = $batch['inventory_id'] ?? null;
if ($qty > 0) { 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; $totalApprovedQty += $qty;
$batchKey = $bNum ?? ''; $batchKey = $bNum ?? '';
$batchGroups[$batchKey] = ($batchGroups[$batchKey] ?? 0) + $qty; $batchGroups[$batchKey] = ($batchGroups[$batchKey] ?? 0) + $qty;
@@ -151,6 +171,18 @@ class StoreRequisitionService
// 無批號,傳統輸入 // 無批號,傳統輸入
$qty = (float)($itemData['approved_qty'] ?? 0); $qty = (float)($itemData['approved_qty'] ?? 0);
if ($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; $totalApprovedQty += $qty;
$processedItems[] = [ $processedItems[] = [
'req_item_id' => $reqItemId, 'req_item_id' => $reqItemId,

View File

@@ -0,0 +1,326 @@
<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\InventoryTransaction;
use App\Modules\Inventory\Models\GoodsReceiptItem;
use App\Modules\Inventory\Models\GoodsReceipt;
use App\Modules\Production\Contracts\ProductionServiceInterface;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Support\Collection;
class TraceabilityService
{
public function __construct(
protected ProductionServiceInterface $productionService,
protected ProcurementServiceInterface $procurementService
) {}
/**
* 逆向溯源:從成品批號往前追溯用到的所有原料與廠商
*
* @param string $batchNumber 成品批號
* @return array 樹狀結構資料
*/
public function traceBackward(string $batchNumber): array
{
// 取得基本庫存資訊以作為根節點參考
$baseInventory = Inventory::with(['product', 'warehouse'])
->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);
}
}
}
}

View File

@@ -5,4 +5,21 @@ namespace App\Modules\Production\Contracts;
interface ProductionServiceInterface interface ProductionServiceInterface
{ {
public function getPendingProductionCount(): int; 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;
} }

View File

@@ -26,4 +26,9 @@ class ProductionOrderItem extends Model
{ {
return $this->belongsTo(ProductionOrder::class); return $this->belongsTo(ProductionOrder::class);
} }
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(\App\Modules\Inventory\Models\Inventory::class);
}
} }

View File

@@ -4,6 +4,7 @@ namespace App\Modules\Production\Services;
use App\Modules\Production\Contracts\ProductionServiceInterface; use App\Modules\Production\Contracts\ProductionServiceInterface;
use App\Modules\Production\Models\ProductionOrder; use App\Modules\Production\Models\ProductionOrder;
use App\Modules\Production\Models\ProductionOrderItem;
class ProductionService implements ProductionServiceInterface class ProductionService implements ProductionServiceInterface
{ {
@@ -11,4 +12,18 @@ class ProductionService implements ProductionServiceInterface
{ {
return ProductionOrder::where('status', 'pending')->count(); 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();
}
} }

View File

@@ -70,6 +70,9 @@ class PermissionSeeder extends Seeder
'inventory_report.view' => '檢視', 'inventory_report.view' => '檢視',
'inventory_report.export' => '匯出', 'inventory_report.export' => '匯出',
// 批號溯源
'inventory_traceability.view' => '檢視',
// 進貨單管理 // 進貨單管理
'goods_receipts.view' => '檢視', 'goods_receipts.view' => '檢視',
'goods_receipts.create' => '建立', 'goods_receipts.create' => '建立',
@@ -191,7 +194,7 @@ class PermissionSeeder extends Seeder
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete', '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_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_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',
'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.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', '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_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.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_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',
'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', 'production_orders.view', 'production_orders.create', 'production_orders.edit',
@@ -246,6 +249,7 @@ class PermissionSeeder extends Seeder
'warehouses.view', 'warehouses.view',
'utility_fees.view', 'utility_fees.view',
'inventory_report.view', 'inventory_report.view',
'inventory_traceability.view',
'accounting.view', 'accounting.view',
'account_payables.view', 'account_payables.view',
'sales_orders.view', 'sales_orders.view',

View File

@@ -278,6 +278,13 @@ export default function AuthenticatedLayout({
route: "/inventory/analysis", route: "/inventory/analysis",
permission: "inventory_report.view", permission: "inventory_report.view",
}, },
{
id: "inventory-traceability",
label: "批號溯源",
icon: <TrendingUp className="h-4 w-4" />,
route: "/inventory/traceability",
permission: "inventory_traceability.view",
},
], ],
}, },
{ {

View File

@@ -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 (
<div className="bg-blue-50/50 border border-blue-100 rounded-xl p-4 mb-6 flex gap-3 text-gray-700 leading-relaxed shadow-sm">
<Info className="w-5 h-5 text-blue-500 shrink-0 mt-0.5" />
<div>
<p>
<strong className="text-gray-900">{data.batch_number}</strong>
{data.product_name && <span> ({data.product_name})</span>}
{directGrNames.map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)}
</p>
</div>
</div>
);
}
return (
<div className="bg-blue-50/50 border border-blue-100 rounded-xl p-4 mb-6 flex gap-3 text-gray-700 leading-relaxed shadow-sm">
<Info className="w-5 h-5 text-blue-500 shrink-0 mt-0.5" />
<div className="space-y-1.5">
<p>
<strong className="text-gray-900">{data.batch_number}</strong>
{data.product_name && <span> ({data.product_name})</span>}
{poNames.length > 0 ? (
<>
{poNames.slice(0, 3).map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)}
{poNames.length > 3 && `${poNames.length} 張工單`}
</>
) : (
'未知的生產工單產出。'
)}
</p>
{materialNodes.length > 0 && (
<p>
使 <strong className="text-gray-900">{materialNodes.length}</strong>
{materialNames.slice(0, 3).map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)}
{materialNames.length > 3 && ' 等'}
</p>
)}
{grNodes.length > 0 && (
<p>
{grNames.slice(0, 3).map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)}
{grNames.length > 3 && `${grNames.length} 張單據`}
</p>
)}
</div>
</div>
);
}
// --- 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 (
<div className="bg-blue-50/50 border border-blue-100 rounded-xl p-4 mb-6 flex gap-3 text-gray-700 leading-relaxed shadow-sm">
<Info className="w-5 h-5 text-blue-500 shrink-0 mt-0.5" />
<div className="space-y-1.5">
<p>
<strong className="text-gray-900">{data.batch_number}</strong>
{data.product_name && <span> ({data.product_name})</span>}
{poNames.length > 0 ? (
<>
{poNames.slice(0, 3).map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)}
{poNames.length > 3 && `${poNames.length} 張工單`}
</>
) : (
' 未知的生產工單。'
)}
</p>
{targetNodes.length > 0 && (
<p>
{targetNames.slice(0, 3).map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)}
{targetNames.length > 3 && `${targetNodes.length} 批成品`}
</p>
)}
{outNodes.length > 0 && (
<p>
{outNames.slice(0, 3).map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)}
{outNames.length > 3 && `${outNodes.length} 張單據`}
</p>
)}
</div>
</div>
);
}
return null;
}

View File

@@ -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 <Package className="h-5 w-5 text-primary-main" />;
case 'production_order':
return <Factory className="h-5 w-5 text-other-warning" />;
case 'material_batch':
return <Box className="h-5 w-5 text-primary-main" />;
case 'goods_receipt':
return <Truck className="h-5 w-5 text-other-info" />;
case 'outbound_transaction':
return <ShoppingCart className="h-5 w-5 text-other-success" />;
default:
return <Box className="h-5 w-5 text-gray-400" />;
}
};
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 (
<div className="relative">
{/* 連接線 (上層到此節點) */}
{level > 0 && (
<div
className={cn(
"absolute border-l-2 border-gray-200",
isLast ? "h-6 top-0" : "h-full top-0"
)}
style={{ left: '-1.5rem' }}
/>
)}
{/* 橫向連接線 */}
{level > 0 && (
<div
className="absolute border-t-2 border-gray-200 w-6 top-6"
style={{ left: '-1.5rem' }}
/>
)}
<div className="flex items-start mb-6">
{level > 0 && (
<div className="w-6 h-12 shrink-0 flex items-center justify-end pr-2 text-gray-400">
<CornerDownRight className="h-4 w-4" />
</div>
)}
<div className={cn(
"relative flex flex-col p-4 rounded-xl border shadow-sm min-w-[320px] max-w-md",
getNodeColor(node.type)
)}>
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-lg shadow-sm shrink-0">
{getNodeIcon(node.type)}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 truncate">{node.label}</h4>
{node.date && (
<div className="mt-0.5">
<span className="text-xs text-gray-500">{formatDate(node.date)}</span>
</div>
)}
</div>
</div>
{(node.product_name || node.warehouse_name || (node.quantity !== undefined && node.quantity !== null)) && (
<div className="mt-3 pt-3 border-t border-gray-200/60 flex flex-wrap gap-4">
{node.product_name && (
<div className="flex-1 min-w-[120px]">
<span className="text-[11px] text-gray-500 font-medium block mb-0.5"> / </span>
<span className="text-sm font-medium text-gray-800 leading-tight block truncate" title={`${node.product_name} ${node.spec ? `(${node.spec})` : ''}`}>
{node.product_name}
{node.spec && <span className="text-gray-400 text-xs ml-1">({node.spec})</span>}
</span>
</div>
)}
{node.warehouse_name && (
<div className="shrink-0 min-w-[80px]">
<span className="text-[11px] text-gray-500 font-medium block mb-0.5"></span>
<span className="text-sm font-medium text-gray-800 leading-tight block truncate" title={node.warehouse_name}>
{node.warehouse_name}
</span>
</div>
)}
{node.quantity !== undefined && node.quantity !== null && (
<div className="shrink-0 text-right min-w-[60px]">
<span className="text-[11px] text-gray-500 font-medium block mb-0.5"></span>
<span className="text-sm font-bold text-gray-900 block truncate">
{Number(node.quantity).toLocaleString()}
<span className="text-[10px] ml-0.5 text-gray-500 font-normal">{node.unit || 'PCS'}</span>
</span>
</div>
)}
</div>
)}
</div>
</div>
{hasChildren && (
<div className="pl-12 relative">
{node.children!.map((child, index) => (
<TreeNode
key={`${child.id} -${index} `}
node={child}
isLast={index === node.children!.length - 1}
level={level + 1}
/>
))}
</div>
)}
</div>
);
};
export default function TreeView({ data }: TreeViewProps) {
if (!data) return null;
return (
<div className="p-4 md:p-8 bg-gray-50/50 rounded-2xl border border-gray-100 overflow-x-auto">
<TreeNode node={data} isLast={true} level={0} />
</div>
);
}

View File

@@ -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 (
<AuthenticatedLayout
breadcrumbs={[
{ label: '報表管理', href: '#' },
{ label: '批號溯源', href: route('inventory.traceability.index'), isPage: true },
]}
>
<Head title="批號溯源 - Star ERP" />
<div className="container mx-auto p-6 max-w-7xl">
<div className="mb-6">
<div className="mb-4">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<TrendingUp className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
</div>
<Can permission="inventory.traceability.view">
<Card className="mb-6 bg-white shadow-sm border-gray-200">
<CardHeader className="pb-3 border-b border-gray-100 bg-gray-50/50">
<CardTitle className="text-lg flex items-center gap-2 text-gray-800">
<Search className="h-5 w-5 text-primary-main" />
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-6 items-start md:items-end">
<div className="flex-1 w-full space-y-1.5">
<Label htmlFor="batchNumber" className="text-sm font-medium text-grey-1"></Label>
<Input
id="batchNumber"
type="text"
placeholder="請輸入欲查詢的批號 (例如PROD-TW-20240101-01)"
value={batchNumber}
onChange={(e) => setBatchNumber(e.target.value)}
className="max-w-md w-full"
/>
</div>
<div className="space-y-3">
<Label className="text-gray-700 font-medium"></Label>
<RadioGroup
value={direction}
onValueChange={(val: 'backward' | 'forward') => setDirection(val)}
className="flex space-x-6"
>
<div
className={cn(
"flex items-center space-x-2 px-4 py-2 rounded-lg border cursor-pointer transition-colors",
direction === 'backward' ? "bg-primary-lightest border-primary-light" : "bg-gray-50 border-gray-200 hover:bg-gray-100"
)}
>
<RadioGroupItem value="backward" id="backward" />
<Label htmlFor="backward" className="cursor-pointer flex items-center gap-1.5 font-medium">
<RotateCcw className="h-4 w-4 text-primary-main" />
( )
</Label>
</div>
<div
className={cn(
"flex items-center space-x-2 px-4 py-2 rounded-lg border cursor-pointer transition-colors",
direction === 'forward' ? "bg-primary-lightest border-primary-light" : "bg-gray-50 border-gray-200 hover:bg-gray-100"
)}
>
<RadioGroupItem value="forward" id="forward" />
<Label htmlFor="forward" className="cursor-pointer flex items-center gap-1.5 font-medium">
<TrendingUp className="h-4 w-4 text-primary-main" />
( )
</Label>
</div>
</RadioGroup>
</div>
<Button
type="submit"
disabled={isSearching || !batchNumber.trim()}
className="button-filled-primary min-w-[120px]"
>
{isSearching ? '查詢中...' : '開始查詢'}
</Button>
</form>
</CardContent>
</Card>
{search.batch_number && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden p-6 md:p-8">
{result ? (
<>
<TraceabilitySummary data={result} direction={search.direction || 'backward'} />
<TreeView data={result} />
</>
) : (
<div className="py-16 flex flex-col items-center justify-center text-gray-500">
<Search className="h-12 w-12 text-gray-300 mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-1"></h3>
<p>{search.batch_number}</p>
</div>
)}
</div>
)}
</Can>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -13,14 +13,8 @@ import {
TableRow, TableRow,
} from "@/Components/ui/table"; } from "@/Components/ui/table";
import { StatusBadge } from "@/Components/shared/StatusBadge"; import { StatusBadge } from "@/Components/shared/StatusBadge";
import { Checkbox } from "@/Components/ui/checkbox"; import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/Components/ui/dialog";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -39,7 +33,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/Components/ui/select"; } 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 { toast } from "sonner";
import axios from "axios"; import axios from "axios";
import { Can } from '@/Components/Permission/Can'; import { Can } from '@/Components/Permission/Can';
@@ -115,20 +109,20 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
} }
}, [order]); }, [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 // Product Selection
const [isProductDialogOpen, setIsProductDialogOpen] = useState(false);
const [availableInventory, setAvailableInventory] = useState<any[]>([]); const [availableInventory, setAvailableInventory] = useState<any[]>([]);
const [loadingInventory, setLoadingInventory] = useState(false); const [loadingInventory, setLoadingInventory] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedInventory, setSelectedInventory] = useState<string[]>([]); // product_id-batch
useEffect(() => { useEffect(() => {
if (isProductDialogOpen) { if (!isItemsReadOnly && order.from_warehouse_id) {
loadInventory(); loadInventory();
setSelectedInventory([]);
setSearchQuery('');
} }
}, [isProductDialogOpen]); }, [isItemsReadOnly, order.from_warehouse_id]);
const loadInventory = async () => { const loadInventory = async () => {
setLoadingInventory(true); setLoadingInventory(true);
@@ -143,57 +137,22 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
} }
}; };
const toggleSelect = (key: string) => { const handleAddItem = () => {
setSelectedInventory(prev => setItems([
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] ...items,
); {
}; product_id: "",
product_name: "",
const toggleSelectAll = () => { product_code: "",
const filtered = availableInventory.filter(inv => batch_number: "",
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) || expiry_date: null,
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) || unit: "",
(inv.product_barcode && inv.product_barcode.toLowerCase().includes(searchQuery.toLowerCase())) quantity: "",
); max_quantity: 0,
const filteredKeys = filtered.map(inv => `${inv.product_id}-${inv.batch_number}`); position: "",
notes: ""
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++;
} }
}); ]);
setItems(newItems);
setIsProductDialogOpen(false);
if (addedCount > 0) {
toast.success(`已成功加入 ${addedCount} 個項目`);
}
}; };
const handleUpdateItem = (index: number, field: string, value: any) => { const handleUpdateItem = (index: number, field: string, value: any) => {
@@ -210,11 +169,16 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
const handleSave = async () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
try { try {
await router.put(route('inventory.transfer.update', [order.id]), { const payload: any = {
items: items,
remarks: remarks, remarks: remarks,
transit_warehouse_id: transitWarehouseId || '', transit_warehouse_id: transitWarehouseId || '',
}, { };
if (!order.requisition) {
payload.items = items;
}
await router.put(route('inventory.transfer.update', [order.id]), payload, {
onSuccess: () => { }, onSuccess: () => { },
onError: () => toast.error("儲存失敗,請檢查輸入"), onError: () => toast.error("儲存失敗,請檢查輸入"),
}); });
@@ -223,15 +187,19 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
} }
}; };
// 確認出貨 / 確認過帳(無在途倉)
// 確認出貨 / 確認過帳(無在途倉) // 確認出貨 / 確認過帳(無在途倉)
const handlePost = () => { const handlePost = () => {
router.put(route('inventory.transfer.update', [order.id]), { const payload: any = {
action: 'post', action: 'post',
transit_warehouse_id: transitWarehouseId || '', transit_warehouse_id: transitWarehouseId || '',
items: items,
remarks: remarks, remarks: remarks,
}, { };
if (!order.requisition) {
payload.items = items;
}
router.put(route('inventory.transfer.update', [order.id]), payload, {
onSuccess: () => { onSuccess: () => {
setIsPostDialogOpen(false); 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 渲染 // 狀態 Badge 渲染
const renderStatusBadge = () => { const renderStatusBadge = () => {
@@ -579,146 +544,10 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
onOpenChange={setIsImportDialogOpen} onOpenChange={setIsImportDialogOpen}
orderId={order.id} orderId={order.id}
/> />
<Button variant="outline" className="button-outlined-primary" onClick={handleAddItem}>
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}> <Plus className="h-4 w-4 mr-2" />
<DialogTrigger asChild>
<Button variant="outline" className="button-outlined-primary"> </Button>
<Plus className="h-4 w-4 mr-2" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col p-6">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<DialogTitle className="text-xl"> ({order.from_warehouse_name})</DialogTitle>
<div className="relative w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-grey-3" />
<Input
placeholder="搜尋品名、代號或條碼..."
className="pl-9 h-9 border-2 border-grey-3 focus:ring-primary-main"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</DialogHeader>
<div className="flex-1 overflow-auto pr-1">
{loadingInventory ? (
<div className="text-center py-12">
<Package className="h-10 w-10 animate-bounce mx-auto text-gray-300 mb-2" />
<p className="text-grey-2 text-sm">...</p>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50/80 sticky top-0 z-10 shadow-sm">
<TableRow>
<TableHead className="w-[50px] text-center">
<Checkbox
checked={availableInventory.length > 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()}
/>
</TableHead>
<TableHead className="font-medium text-grey-600"> / </TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="text-right font-medium text-grey-600 pr-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
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 (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-grey-3 italic font-medium">
{searchQuery ? `找不到與 "${searchQuery}" 相關的商品` : '尚無庫存資料'}
</TableCell>
</TableRow>
);
}
return filtered.map((inv) => {
const key = `${inv.product_id}-${inv.batch_number}`;
const isSelected = selectedInventory.includes(key);
return (
<TableRow
key={key}
className={`hover:bg-primary-lightest/20 cursor-pointer transition-colors ${isSelected ? 'bg-primary-lightest/40' : ''}`}
onClick={() => toggleSelect(key)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(key)}
/>
</TableCell>
<TableCell className="py-3">
<div className="flex flex-col">
<span className="font-semibold text-grey-0">{inv.product_name}</span>
<span className="text-xs text-grey-2 font-mono">{inv.product_code}</span>
</div>
</TableCell>
<TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell>
<TableCell className="text-sm font-mono text-grey-2">{inv.expiry_date || '-'}</TableCell>
<TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell>
</TableRow>
);
});
})()}
</TableBody>
</Table>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between border-t pt-4">
<div className="flex items-center gap-3">
<div className="px-3 py-1 bg-primary-lightest/50 border border-primary-light/20 rounded-full text-sm font-medium text-primary-main animate-in zoom-in duration-200">
{selectedInventory.length}
</div>
{selectedInventory.length > 0 && (
<Button
variant="ghost"
size="sm"
className="text-grey-3 hover:text-red-500 hover:bg-red-50 text-xs px-2 h-7"
onClick={() => setSelectedInventory([])}
>
</Button>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="button-outlined-primary w-24"
onClick={() => setIsProductDialogOpen(false)}
>
</Button>
<Button
className="button-filled-primary min-w-32"
disabled={selectedInventory.length === 0}
onClick={handleAddSelected}
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div> </div>
)} )}
</div> </div>
@@ -752,10 +581,41 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
<TableRow key={index}> <TableRow key={index}>
<TableCell className="text-center text-gray-500 font-medium">{index + 1}</TableCell> <TableCell className="text-center text-gray-500 font-medium">{index + 1}</TableCell>
<TableCell className="py-3"> <TableCell className="py-3">
<div className="flex flex-col"> {isItemsReadOnly || item.product_id ? (
<span className="font-semibold text-gray-900">{item.product_name}</span> <div className="flex flex-col">
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span> <span className="font-semibold text-gray-900">{item.product_name}</span>
</div> <span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
</div>
) : (
<SearchableSelect
value={item.product_id ? `${item.product_id}|${item.batch_number || ''}` : ""}
onValueChange={(val) => {
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]"
/>
)}
</TableCell> </TableCell>
<TableCell className="text-sm font-mono"> <TableCell className="text-sm font-mono">
<div>{item.batch_number || '-'}</div> <div>{item.batch_number || '-'}</div>
@@ -766,7 +626,7 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
)} )}
</TableCell> </TableCell>
<TableCell className="text-right font-semibold text-primary-main"> <TableCell className="text-right font-semibold text-primary-main">
{item.max_quantity} {item.unit || item.unit_name} {item.product_id ? `${item.max_quantity} ${item.unit || item.unit_name || ''}` : '-'}
</TableCell> </TableCell>
<TableCell className="px-1 py-3"> <TableCell className="px-1 py-3">
{isItemsReadOnly ? ( {isItemsReadOnly ? (

View File

@@ -175,15 +175,33 @@ export default function Show({ requisition, warehouses }: Props) {
}; };
const handleApprove = () => { const handleApprove = () => {
// 確認每個核准數量 // 確認每個核准數量與庫存上限
for (const item of approvedItems) { for (const item of approvedItems) {
const originalItem = requisition.items.find(i => i.id === item.id);
if (!originalItem) continue;
for (const batch of item.batches) { for (const batch of item.batches) {
if (batch.qty !== "") { if (batch.qty !== "") {
const qty = parseFloat(batch.qty); const qty = parseFloat(batch.qty);
if (isNaN(qty) || qty < 0) { if (isNaN(qty) || qty < 0) {
toast.error("核准數量不能為負數"); toast.error("核准數量不能為負數或無效數字");
return; 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;
}
}
} }
} }
} }