From e018b757830caa70f967668e298e144bbb034bde Mon Sep 17 00:00:00 2001 From: sky121113 Date: Fri, 6 Feb 2026 16:36:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20=E9=96=8B=E6=94=BE=E5=80=89?= =?UTF-8?q?=E5=BA=AB=E7=B7=A8=E8=99=9F=E7=B7=A8=E8=BC=AF=E3=80=81=E5=84=AA?= =?UTF-8?q?=E5=8C=96=E8=AA=BF=E6=92=A5=E5=96=AE=E6=A2=9D=E7=A2=BC=E6=90=9C?= =?UTF-8?q?=E5=B0=8B=E8=88=87=E5=BA=AB=E5=AD=98=E5=8C=AF=E5=85=A5=E7=AF=84?= =?UTF-8?q?=E6=9C=AC=E9=9B=99=E5=88=86=E9=A0=81=E8=AA=AA=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agent/skills/ui-consistency/SKILL.md | 74 +++++++ .../Controllers/InventoryController.php | 35 +++ .../Controllers/TransferOrderController.php | 3 +- .../Controllers/WarehouseController.php | 10 +- .../Exports/InventoryTemplateExport.php | 79 +++++++ .../Inventory/Imports/InventoryImport.php | 122 +++++++++++ app/Modules/Inventory/Routes/web.php | 2 + .../Inventory/InventoryImportDialog.tsx | 203 ++++++++++++++++++ .../Components/Warehouse/WarehouseDialog.tsx | 11 +- .../js/Pages/Inventory/Transfer/Show.tsx | 13 +- resources/js/Pages/Warehouse/Inventory.tsx | 23 +- 11 files changed, 555 insertions(+), 20 deletions(-) create mode 100644 app/Modules/Inventory/Exports/InventoryTemplateExport.php create mode 100644 app/Modules/Inventory/Imports/InventoryImport.php create mode 100644 resources/js/Components/Warehouse/Inventory/InventoryImportDialog.tsx diff --git a/.agent/skills/ui-consistency/SKILL.md b/.agent/skills/ui-consistency/SKILL.md index 9cac96b..db63ef8 100644 --- a/.agent/skills/ui-consistency/SKILL.md +++ b/.agent/skills/ui-consistency/SKILL.md @@ -1086,3 +1086,77 @@ import { Pencil } from 'lucide-react'; 5. ✅ **安全性**:統一的權限控制確保資料安全 當你在開發或審查 Star ERP 客戶端後台的 UI 時,請務必參考此規範! + +--- + +## 15. 批次匯入彈窗規範 (Batch Import Dialog) + +為了確保系統中所有批次匯入功能(如:商品、庫存、客戶)的體驗一致,必須遵循以下 UI 結構與樣式。 + +### 15.1 標題結構 + +- **樣式**:保持簡潔,僅使用文字標題,不帶額外圖示。 +- **文字**:統一為「匯入XXXX資料」。 + +```tsx + + 匯入商品資料 + + 請先下載範本,填寫完畢後上傳檔案進行批次處理。 + + +``` + +### 15.2 分步引導區塊 (Step-by-Step Guide) + +匯入流程必須分為三個清晰的步驟區塊: + +#### 步驟 1:取得匯入範本 +- **容器樣式**:`bg-gray-50 rounded-lg border border-gray-100 p-4 space-y-2` +- **標題圖示**:`` +- **下載按鈕**:`variant="outline" size="sm" className="w-full sm:w-auto button-outlined-primary"`,並明確標註 `.xlsx`。 + +#### 步驟 2:設定資訊 (選甜) +- **容器樣式**:`space-y-2` +- **標題圖示**:`` +- **欄位樣式**:使用標準 `Input`,標籤文字使用 `text-sm text-gray-700`。 +- **預設值**:若有備註欄位,應提供合適的預設值(例如:「Excel 匯入」)。 + +#### 步驟 3:上傳填寫後的檔案 +- **容器樣式**:`space-y-2` +- **標題圖示**:`` +- **Input 樣式**:`type="file"`,並開啟 `cursor-pointer`。 + +### 15.3 規則說明面板 (Accordion Rules) + +詳細的填寫說明必須收納於 `Accordion` 中,避免干擾主流程: + +- **樣式**:標準灰色邊框,不使用特殊背景色 (如琥珀色)。 +- **容器**:`className="w-full border rounded-lg px-2"` +- **觸發文字**:`text-sm text-gray-500` + +```tsx + + + +
+ + 匯入規則與提示 +
+
+ +
+
    +
  • 使用加粗文字標註關鍵欄位:關鍵字
  • +
  • 說明文字簡潔明瞭。
  • +
+
+
+
+
+``` + +### 15.4 底部操作 (Footer) + +- **取消按鈕**:`variant="outline"`,且為 `button-outlined-primary`。 +- **提交按鈕**:`button-filled-primary`,且在處理中時顯示 `Loader2`。 diff --git a/app/Modules/Inventory/Controllers/InventoryController.php b/app/Modules/Inventory/Controllers/InventoryController.php index d77f6ab..eb768e7 100644 --- a/app/Modules/Inventory/Controllers/InventoryController.php +++ b/app/Modules/Inventory/Controllers/InventoryController.php @@ -12,6 +12,10 @@ use App\Modules\Inventory\Models\Product; use App\Modules\Inventory\Models\Inventory; use App\Modules\Inventory\Models\InventoryTransaction; use App\Modules\Inventory\Models\WarehouseProductSafetyStock; +use App\Modules\Inventory\Imports\InventoryImport; +use App\Modules\Inventory\Exports\InventoryTemplateExport; +use Maatwebsite\Excel\Facades\Excel; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use App\Modules\Core\Contracts\CoreServiceInterface; @@ -603,4 +607,35 @@ class InventoryController extends Controller return redirect()->back()->with('error', '未提供查詢參數'); } + + /** + * 匯入入庫 + */ + public function import(Request $request, Warehouse $warehouse) + { + $request->validate([ + 'file' => 'required|mimes:xlsx,xls,csv', + 'inboundDate' => 'required|date', + 'notes' => 'nullable|string', + ]); + + try { + Excel::import( + new InventoryImport($warehouse, $request->inboundDate, $request->notes), + $request->file('file') + ); + + return back()->with('success', '庫存資料匯入成功'); + } catch (\Exception $e) { + return back()->withErrors(['file' => '匯入過程中發生錯誤: ' . $e->getMessage()]); + } + } + + /** + * 下載匯入範本 (.xlsx) + */ + public function template() + { + return Excel::download(new InventoryTemplateExport, '庫存匯入範本.xlsx'); + } } diff --git a/app/Modules/Inventory/Controllers/TransferOrderController.php b/app/Modules/Inventory/Controllers/TransferOrderController.php index 75a4a6a..881dfd9 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -212,7 +212,8 @@ class TransferOrderController extends Controller return [ 'product_id' => (string) $inv->product_id, 'product_name' => $inv->product->name, - 'product_code' => $inv->product->code, // Added code + 'product_code' => $inv->product->code, + 'product_barcode' => $inv->product->barcode, 'batch_number' => $inv->batch_number, 'quantity' => (float) $inv->quantity, 'unit_cost' => (float) $inv->unit_cost, diff --git a/app/Modules/Inventory/Controllers/WarehouseController.php b/app/Modules/Inventory/Controllers/WarehouseController.php index 000bf80..c764460 100644 --- a/app/Modules/Inventory/Controllers/WarehouseController.php +++ b/app/Modules/Inventory/Controllers/WarehouseController.php @@ -123,6 +123,7 @@ class WarehouseController extends Controller public function store(Request $request) { $validated = $request->validate([ + 'code' => 'required|string|max:20|unique:warehouses,code', 'name' => 'required|string|max:50', 'address' => 'nullable|string|max:255', 'description' => 'nullable|string', @@ -131,14 +132,6 @@ class WarehouseController extends Controller 'driver_name' => 'nullable|string|max:50', ]); - // 自動產生代碼 - $prefix = 'WH'; - $lastWarehouse = Warehouse::latest('id')->first(); - $nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1; - $code = $prefix . str_pad($nextId, 3, '0', STR_PAD_LEFT); - - $validated['code'] = $code; - Warehouse::create($validated); return redirect()->back()->with('success', '倉庫已建立'); @@ -147,6 +140,7 @@ class WarehouseController extends Controller public function update(Request $request, Warehouse $warehouse) { $validated = $request->validate([ + 'code' => 'required|string|max:20|unique:warehouses,code,' . $warehouse->id, 'name' => 'required|string|max:50', 'address' => 'nullable|string|max:255', 'description' => 'nullable|string', diff --git a/app/Modules/Inventory/Exports/InventoryTemplateExport.php b/app/Modules/Inventory/Exports/InventoryTemplateExport.php new file mode 100644 index 0000000..139c1bf --- /dev/null +++ b/app/Modules/Inventory/Exports/InventoryTemplateExport.php @@ -0,0 +1,79 @@ +warehouse = $warehouse; + $this->inboundDate = $inboundDate; + $this->notes = $notes; + } + + public function map($row): array + { + // 處理條碼或代號為字串 + if (isset($row['商品條碼'])) { + $row['商品條碼'] = (string) $row['商品條碼']; + } + if (isset($row['商品代號'])) { + $row['商品代號'] = (string) $row['商品代號']; + } + return $row; + } + + public function model(array $row) + { + // 查找商品 + $product = null; + if (!empty($row['商品條碼'])) { + $product = Product::where('barcode', $row['商品條碼'])->first(); + } + if (!$product && !empty($row['商品代號'])) { + $product = Product::where('code', $row['商品代號'])->first(); + } + + if (!$product) { + return null; // 透過 Validation 攔截 + } + + $quantity = (float) $row['數量']; + $unitCost = isset($row['入庫單價']) ? (float) $row['入庫單價'] : ($product->cost_price ?? 0); + + // 批號邏輯:若 Excel 留空則使用 NO-BATCH + $batchNumber = !empty($row['批號']) ? $row['批號'] : 'NO-BATCH'; + $originCountry = $row['產地'] ?? 'TW'; + $expiryDate = !empty($row['效期']) ? $row['效期'] : null; + + return DB::transaction(function () use ($product, $quantity, $unitCost, $batchNumber, $originCountry, $expiryDate) { + // 使用與 InventoryController 相同的 firstOrNew 邏輯 + $inventory = $this->warehouse->inventories()->withTrashed()->firstOrNew( + [ + 'product_id' => $product->id, + 'batch_number' => $batchNumber + ], + [ + 'quantity' => 0, + 'unit_cost' => $unitCost, + 'total_value' => 0, + 'arrival_date' => $this->inboundDate, + 'expiry_date' => $expiryDate, + 'origin_country' => $originCountry, + ] + ); + + if ($inventory->trashed()) { + $inventory->restore(); + } + + // 更新數量 + $oldQty = $inventory->quantity; + $inventory->quantity += $quantity; + + // 更新單價與總價值 + $inventory->unit_cost = $unitCost; + $inventory->total_value = $inventory->quantity * $unitCost; + $inventory->save(); + + // 記錄交易歷史 + $inventory->transactions()->create([ + 'warehouse_id' => $this->warehouse->id, + 'product_id' => $product->id, + 'batch_number' => $inventory->batch_number, + 'quantity' => $quantity, + 'unit_cost' => $unitCost, + 'transaction_type' => '手動入庫', + 'reason' => 'Excel 匯入入庫', + 'notes' => $this->notes, + 'expiry_date' => $inventory->expiry_date, + ]); + + return $inventory; + }); + } + + public function rules(): array + { + return [ + '商品條碼' => ['nullable', 'string'], + '商品代號' => ['nullable', 'string'], + '數量' => ['required', 'numeric', 'min:0.01'], + '入庫單價' => ['nullable', 'numeric', 'min:0'], + '效期' => ['nullable', 'date'], + '產地' => ['nullable', 'string', 'max:2'], + ]; + } +} diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index cd13237..fda81a3 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -56,6 +56,8 @@ Route::middleware('auth')->group(function () { Route::middleware('permission:inventory.adjust')->group(function () { Route::get('/warehouses/{warehouse}/inventory/create', [InventoryController::class, 'create'])->name('warehouses.inventory.create'); Route::post('/warehouses/{warehouse}/inventory', [InventoryController::class, 'store'])->name('warehouses.inventory.store'); + Route::get('/warehouses/inventory/template', [InventoryController::class, 'template'])->name('warehouses.inventory.template'); + Route::post('/warehouses/{warehouse}/inventory/import', [InventoryController::class, 'import'])->name('warehouses.inventory.import'); Route::get('/warehouses/{warehouse}/inventory/{inventoryId}/edit', [InventoryController::class, 'edit'])->name('warehouses.inventory.edit'); Route::put('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'update'])->name('warehouses.inventory.update'); Route::delete('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'destroy'])->name('warehouses.inventory.destroy'); diff --git a/resources/js/Components/Warehouse/Inventory/InventoryImportDialog.tsx b/resources/js/Components/Warehouse/Inventory/InventoryImportDialog.tsx new file mode 100644 index 0000000..5695cb3 --- /dev/null +++ b/resources/js/Components/Warehouse/Inventory/InventoryImportDialog.tsx @@ -0,0 +1,203 @@ +import React from "react"; +import { useForm } from "@inertiajs/react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/Components/ui/dialog"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { Label } from "@/Components/ui/label"; +import { Download, FileUp, Loader2, AlertCircle, FileSpreadsheet, Info } from "lucide-react"; +import { toast } from "sonner"; +import { Alert, AlertDescription } from "@/Components/ui/alert"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/Components/ui/accordion"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + warehouseId: string; +} + +export default function InventoryImportDialog({ open, onOpenChange, warehouseId }: Props) { + const { data, setData, post, processing, errors, reset, clearErrors } = useForm({ + file: null as File | null, + inboundDate: new Date().toISOString().split('T')[0], + notes: "Excel 匯入", + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + post(route("warehouses.inventory.import", warehouseId), { + forceFormData: true, + onSuccess: () => { + toast.success("庫存匯入完成"); + onOpenChange(false); + reset(); + }, + onError: (err) => { + console.error("Import error:", err); + toast.error("匯入失敗,請檢查檔案格式"); + } + }); + }; + + const handleDownloadTemplate = () => { + window.location.href = route("warehouses.inventory.template"); + }; + + return ( + { + onOpenChange(val); + if (!val) { + reset(); + clearErrors(); + } + }}> + + + 匯入庫存資料 + + 請先下載範本,填寫完畢後上傳檔案進行批次入庫。 + + + +
+ {/* 步驟 1: 下載範本 */} +
+ +
+ 下載標準範本以確保資料格式正確。請勿修改標題欄位。 +
+ +
+ + {/* 步驟 2: 設定資訊 */} +
+ + +
+
+ + setData('inboundDate', e.target.value)} + required + className="cursor-pointer" + /> +
+
+ + setData('notes', e.target.value)} + /> +
+
+
+ + {/* 步驟 3: 上傳檔案 */} +
+ +
+ setData('file', e.target.files ? e.target.files[0] : null)} + required + className="cursor-pointer" + /> +
+ {errors.file && ( + + + + {errors.file} + + + )} +
+ + {/* 欄位說明 */} + + + +
+ + 庫存匯入規則與提示 +
+
+ +
+
    +
  • 商品匹配:優先使用「商品條碼」匹配,其次為「商品代號」。
  • +
  • 無批號模式:若 Excel 中的「批號」欄位保持空白,系統將自動累加至該商品的「通用紀錄」。
  • +
  • 效期設定:若商品無效期概念可留空,或輸入格式如:2026/12/31。
  • +
  • 入庫單價:未填寫時將預設使用商品的「採購成本價」。
  • +
+
+
+
+
+ + + + + +
+
+
+ ); +} diff --git a/resources/js/Components/Warehouse/WarehouseDialog.tsx b/resources/js/Components/Warehouse/WarehouseDialog.tsx index efcea62..3e860fa 100644 --- a/resources/js/Components/Warehouse/WarehouseDialog.tsx +++ b/resources/js/Components/Warehouse/WarehouseDialog.tsx @@ -143,14 +143,15 @@ export default function WarehouseDialog({ {/* 倉庫編號 */}
setFormData({ ...formData, code: e.target.value })} + placeholder="請輸入倉庫編號" + required + className="h-9" />
diff --git a/resources/js/Pages/Inventory/Transfer/Show.tsx b/resources/js/Pages/Inventory/Transfer/Show.tsx index 1fce89e..469c91c 100644 --- a/resources/js/Pages/Inventory/Transfer/Show.tsx +++ b/resources/js/Pages/Inventory/Transfer/Show.tsx @@ -84,7 +84,8 @@ export default function Show({ order }: any) { const toggleSelectAll = () => { const filtered = availableInventory.filter(inv => inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) || - inv.product_code.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}`); @@ -338,10 +339,10 @@ export default function Show({ order }: any) { 選擇來源庫存 ({order.from_warehouse_name}) -
+
setSearchQuery(e.target.value)} @@ -364,7 +365,8 @@ export default function Show({ order }: any) { 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_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)); @@ -383,7 +385,8 @@ export default function Show({ order }: any) { {(() => { const filtered = availableInventory.filter(inv => inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) || - inv.product_code.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) { diff --git a/resources/js/Pages/Warehouse/Inventory.tsx b/resources/js/Pages/Warehouse/Inventory.tsx index b051a64..0ea0269 100644 --- a/resources/js/Pages/Warehouse/Inventory.tsx +++ b/resources/js/Pages/Warehouse/Inventory.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from "react"; -import { ArrowLeft, PackagePlus, AlertTriangle, Shield, Boxes } from "lucide-react"; +import { ArrowLeft, PackagePlus, AlertTriangle, Shield, Boxes, FileUp } from "lucide-react"; import { Button } from "@/Components/ui/button"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Head, Link, router } from "@inertiajs/react"; @@ -20,6 +20,7 @@ import { AlertDialogTitle, } from "@/Components/ui/alert-dialog"; import { Can } from "@/Components/Permission/Can"; +import InventoryImportDialog from "@/Components/Warehouse/Inventory/InventoryImportDialog"; // 庫存頁面 Props interface Props { @@ -38,6 +39,7 @@ export default function WarehouseInventoryPage({ const [searchTerm, setSearchTerm] = useState(""); const [typeFilter, setTypeFilter] = useState("all"); const [deleteId, setDeleteId] = useState(null); + const [importDialogOpen, setImportDialogOpen] = useState(false); // 篩選庫存列表 const filteredInventories = useMemo(() => { @@ -157,6 +159,18 @@ export default function WarehouseInventoryPage({ 庫存警告:{lowStockItems} 項 + {/* 匯入入庫按鈕 */} + + + + {/* 新增庫存按鈕 */} @@ -210,6 +224,13 @@ export default function WarehouseInventoryPage({ + + {/* 匯入對話框 */} +
);