diff --git a/app/Modules/Inventory/Controllers/TransferOrderController.php b/app/Modules/Inventory/Controllers/TransferOrderController.php index 881dfd9..f4532f3 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -108,6 +108,7 @@ class TransferOrderController extends Controller 'from_warehouse_name' => $order->fromWarehouse->name, 'to_warehouse_id' => (string) $order->to_warehouse_id, 'to_warehouse_name' => $order->toWarehouse->name, + 'to_warehouse_type' => $order->toWarehouse->type->value, // 用於判斷是否為販賣機 'status' => $order->status, 'remarks' => $order->remarks, 'created_at' => $order->created_at->format('Y-m-d H:i'), @@ -128,6 +129,7 @@ class TransferOrderController extends Controller 'expiry_date' => $stock && $stock->expiry_date ? $stock->expiry_date->format('Y-m-d') : null, 'unit' => $item->product->baseUnit?->name, 'quantity' => (float) $item->quantity, + 'position' => $item->position, 'max_quantity' => $item->snapshot_quantity ? (float) $item->snapshot_quantity : ($stock ? (float) $stock->quantity : 0.0), 'notes' => $item->notes, ]; @@ -145,31 +147,32 @@ class TransferOrderController extends Controller return redirect()->back()->with('error', '只能修改草稿狀態的單據'); } - $validated = $request->validate([ - 'items' => 'array', - 'items.*.product_id' => 'required|exists:products,id', - 'items.*.quantity' => 'required|numeric|min:0.01', - 'items.*.batch_number' => 'nullable|string', - 'items.*.notes' => 'nullable|string', - 'remarks' => 'nullable|string', - ]); - - // 1. 先更新資料 + // 1. 先更新資料 (如果請求中包含 items,則先執行儲存) $itemsChanged = false; if ($request->has('items')) { + $validated = $request->validate([ + 'items' => 'array', + 'items.*.product_id' => 'required|exists:products,id', + 'items.*.quantity' => 'required|numeric|min:0.01', + 'items.*.batch_number' => 'nullable|string', + 'items.*.position' => 'nullable|string', + 'items.*.notes' => 'nullable|string', + ]); $itemsChanged = $this->transferService->updateItems($order, $validated['items']); } - $remarksChanged = $order->remarks !== ($validated['remarks'] ?? null); - + $remarksChanged = false; + if ($request->has('remarks')) { + $remarksChanged = $order->remarks !== $request->input('remarks'); + $order->remarks = $request->input('remarks'); + } + if ($itemsChanged || $remarksChanged) { - $order->remarks = $validated['remarks'] ?? null; // [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌 $order->touch(); $message = '儲存成功'; } else { $message = '資料未變更'; - // 如果沒變更,就不執行 touch(),也不會產生 Activity Log } // 2. 判斷是否需要過帳 @@ -178,8 +181,10 @@ class TransferOrderController extends Controller $this->transferService->post($order, auth()->id()); return redirect()->route('inventory.transfer.index') ->with('success', '調撥單已過帳完成'); + } catch (ValidationException $e) { + return redirect()->back()->withErrors($e->errors()); } catch (\Exception $e) { - return redirect()->back()->withErrors(['items' => $e->getMessage()]); + return redirect()->back()->withErrors(['items' => $e->getMessage()]); } } @@ -224,4 +229,30 @@ class TransferOrderController extends Controller return response()->json($inventories); } + public function importItems(Request $request, InventoryTransferOrder $order) + { + if ($order->status !== 'draft') { + return redirect()->back()->with('error', '只能在草稿狀態下匯入明細'); + } + + $request->validate([ + 'file' => 'required|file|mimes:xlsx,xls,csv', + ]); + + try { + \Maatwebsite\Excel\Facades\Excel::import(new \App\Modules\Inventory\Imports\InventoryTransferItemImport($order), $request->file('file')); + return redirect()->back()->with('success', '匯入成功'); + } catch (\Exception $e) { + return redirect()->back()->with('error', '匯入失敗:' . $e->getMessage()); + } + } + + public function template() + { + return \Maatwebsite\Excel\Facades\Excel::download( + new \App\Modules\Inventory\Exports\InventoryTransferTemplateExport(), + '調撥單明細匯入範本.xlsx' + ); + } } + diff --git a/app/Modules/Inventory/Exports/InventoryTransferTemplateExport.php b/app/Modules/Inventory/Exports/InventoryTransferTemplateExport.php new file mode 100644 index 0000000..84d5d7d --- /dev/null +++ b/app/Modules/Inventory/Exports/InventoryTransferTemplateExport.php @@ -0,0 +1,81 @@ + ['font' => ['bold' => true]], + ]; + } + }, + new class implements FromCollection, WithHeadings, WithTitle, WithStyles { + public function collection() + { + return collect([ + ['商品代碼', '必填', '請填寫系統中已存在的商品代號'], + ['數量', '必填', '必須為大於 0 的數字'], + ['批號', '選填', '若不填寫將自動對應「NO-BATCH」庫存'], + ['貨道/儲位', '選填', '主要用於目的倉庫為「販賣機」時指定貨道'], + ['備註', '選填', '可填寫該筆明細的備註說明'], + ['', '', ''], + ['提示', '附加模式', '匯入的明細將附加至現有單據,不會覆蓋原有資料'], + ]); + } + + public function headings(): array + { + return ['欄位名稱', '必要性', '說明']; + } + + public function title(): string + { + return '匯入規則說明'; + } + + public function styles(Worksheet $sheet) + { + $sheet->getColumnDimension('A')->setWidth(15); + $sheet->getColumnDimension('B')->setWidth(15); + $sheet->getColumnDimension('C')->setWidth(50); + return [ + 1 => ['font' => ['bold' => true]], + ]; + } + }, + ]; + } +} diff --git a/app/Modules/Inventory/Imports/InventoryTransferItemImport.php b/app/Modules/Inventory/Imports/InventoryTransferItemImport.php new file mode 100644 index 0000000..efaf21e --- /dev/null +++ b/app/Modules/Inventory/Imports/InventoryTransferItemImport.php @@ -0,0 +1,131 @@ +transferOrder = $transferOrder; + } + + public function collection(Collection $rows) + { + if ($rows->isEmpty()) { + throw new Exception("檔案中沒有資料。"); + } + + // 移除標題列並解析索引 + $headerRow = $rows->shift(); + $headers = $headerRow->toArray(); + + // 建立標題對應索引 (支援中文與英文) + $colMap = [ + 'product_code' => -1, + 'batch_number' => -1, + 'quantity' => -1, + 'position' => -1, + 'notes' => -1, + ]; + + foreach ($headers as $index => $label) { + $label = trim((string)$label); + if (in_array($label, ['商品代碼', 'product_code', 'shang_pin_dai_ma'])) $colMap['product_code'] = $index; + if (in_array($label, ['批號', 'batch_number', 'pi_hao'])) $colMap['batch_number'] = $index; + if (in_array($label, ['數量', 'quantity', 'shu_liang'])) $colMap['quantity'] = $index; + if (in_array($label, ['貨道/儲位', '貨道', 'position', 'slot', 'huo_dao'])) $colMap['position'] = $index; + if (in_array($label, ['備註', 'notes', 'bei_zhu'])) $colMap['notes'] = $index; + } + + // 檢查必要欄位是否有找到 + if ($colMap['product_code'] === -1 || $colMap['quantity'] === -1) { + $foundHeaders = implode(', ', array_filter($headers)); + throw new Exception("找不到必要的欄位「商品代碼」或「數量」。讀取到的標題為:{$foundHeaders}。請確認使用的是正確的範本。"); + } + + // 預先載入商品 (優化效能) + $productCodes = $rows->map(fn($row) => trim((string)($row[$colMap['product_code']] ?? '')))->filter()->unique()->toArray(); + $products = Product::whereIn('code', $productCodes)->get()->keyBy('code'); + + $newItems = []; + $errors = []; + + foreach ($rows as $index => $row) { + $productCode = trim((string)($row[$colMap['product_code']] ?? '')); + $quantity = $row[$colMap['quantity']] ?? null; + $batchNumber = $colMap['batch_number'] !== -1 ? trim((string)($row[$colMap['batch_number']] ?? '')) : ''; + $position = $colMap['position'] !== -1 ? trim((string)($row[$colMap['position']] ?? '')) : null; + $notes = $colMap['notes'] !== -1 ? ($row[$colMap['notes']] ?? null) : null; + + // 跳過全空行 + if (empty($productCode) && ($quantity === null || $quantity === '')) { + continue; + } + + $lineNum = $index + 2; // 因為 shift 過,且 Excel 從 1 開始 + + if (empty($productCode)) { + $errors[] = "第 {$lineNum} 行:商品代碼不能為空"; + continue; + } + + $product = $products->get($productCode); + if (!$product) { + $errors[] = "第 {$lineNum} 行:找不到商品代碼 '{$productCode}'"; + continue; + } + + if (!is_numeric($quantity) || (float)$quantity <= 0) { + $errors[] = "第 {$lineNum} 行:數量必須為大於 0 的數字 (目前值: " . ($quantity ?? '空') . ")"; + continue; + } + + if (empty($batchNumber)) { + $batchNumber = 'NO-BATCH'; + } + + $newItems[] = [ + 'transfer_order_id' => $this->transferOrder->id, + 'product_id' => $product->id, + 'batch_number' => $batchNumber, + 'quantity' => (float)$quantity, + 'position' => $position, + 'notes' => $notes, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + if (count($errors) > 0) { + throw new Exception(implode("\n", $errors)); + } + + if (count($newItems) === 0) { + throw new Exception("檔案中沒有可匯入的有效資料。"); + } + + InventoryTransferItem::insert($newItems); + $this->transferOrder->touch(); + } + + /** + * 指定只匯入第一個分頁 (明細匯入) + */ + public function sheets(): array + { + return [ + 0 => $this, + ]; + } +} diff --git a/app/Modules/Inventory/Models/InventoryTransferItem.php b/app/Modules/Inventory/Models/InventoryTransferItem.php index 43366d2..03ae3f6 100644 --- a/app/Modules/Inventory/Models/InventoryTransferItem.php +++ b/app/Modules/Inventory/Models/InventoryTransferItem.php @@ -15,6 +15,7 @@ class InventoryTransferItem extends Model 'product_id', 'batch_number', 'quantity', + 'position', 'snapshot_quantity', 'notes', ]; diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index fda81a3..c091dc4 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -112,6 +112,16 @@ Route::middleware('auth')->group(function () { ->middleware('permission:inventory.view') ->name('api.warehouses.inventories'); + // 調撥單匯入明細 + Route::post('/inventory/transfer-orders/{order}/import', [TransferOrderController::class, 'importItems']) + ->middleware('permission:inventory_transfer.edit') + ->name('inventory.transfer.import-items'); + + // 下載調撥單匯入範本 + Route::get('/inventory/transfer-orders/template/download', [TransferOrderController::class, 'template']) + ->middleware('permission:inventory_transfer.view') + ->name('inventory.transfer.template'); + // 進貨單 (Goods Receipts) Route::middleware('permission:goods_receipts.view')->group(function () { Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index'); diff --git a/app/Modules/Inventory/Services/TransferService.php b/app/Modules/Inventory/Services/TransferService.php index 70a47c5..5f00819 100644 --- a/app/Modules/Inventory/Services/TransferService.php +++ b/app/Modules/Inventory/Services/TransferService.php @@ -63,6 +63,7 @@ class TransferService 'product_id' => $data['product_id'], 'batch_number' => $data['batch_number'] ?? null, 'quantity' => $data['quantity'], + 'position' => $data['position'] ?? null, 'notes' => $data['notes'] ?? null, ]); // Eager load product for name @@ -72,17 +73,20 @@ class TransferService if ($oldItemsMap->has($key)) { $oldItem = $oldItemsMap->get($key); // 檢查數值是否有變動 - if ((float)$oldItem->quantity !== (float)$data['quantity'] || - $oldItem->notes !== ($data['notes'] ?? null)) { + if ((float)$oldItem->quantity !== (float)$data['quantity'] || + $oldItem->notes !== ($data['notes'] ?? null) || + $oldItem->position !== ($data['position'] ?? null)) { $diff['updated'][] = [ 'product_name' => $item->product->name, 'old' => [ 'quantity' => (float)$oldItem->quantity, + 'position' => $oldItem->position, 'notes' => $oldItem->notes, ], 'new' => [ 'quantity' => (float)$data['quantity'], + 'position' => $item->position, 'notes' => $item->notes, ] ]; @@ -148,8 +152,10 @@ class TransferService ->first(); if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) { + $availableQty = $sourceInventory->quantity ?? 0; + $shortageQty = $item->quantity - $availableQty; throw ValidationException::withMessages([ - 'items' => ["商品 {$item->product->name} (批號: {$item->batch_number}) 在來源倉庫存不足"], + 'items' => ["商品 {$item->product->name} (批號: {$item->batch_number}) 在來源倉庫存不足。現有庫存:{$availableQty},尚欠:{$shortageQty}。"], ]); } @@ -182,6 +188,7 @@ class TransferService 'warehouse_id' => $order->to_warehouse_id, 'product_id' => $item->product_id, 'batch_number' => $item->batch_number, + 'location' => $item->position, // 同步貨道至庫存位置 ], [ 'quantity' => 0, diff --git a/database/migrations/tenant/2026_02_09_163350_add_position_to_inventory_transfer_items.php b/database/migrations/tenant/2026_02_09_163350_add_position_to_inventory_transfer_items.php new file mode 100644 index 0000000..c3323e0 --- /dev/null +++ b/database/migrations/tenant/2026_02_09_163350_add_position_to_inventory_transfer_items.php @@ -0,0 +1,28 @@ +string('position')->nullable()->after('quantity')->comment('貨道/儲位'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_transfer_items', function (Blueprint $table) { + $table->dropColumn('position'); + }); + } +}; diff --git a/resources/js/Components/Transfer/TransferImportDialog.tsx b/resources/js/Components/Transfer/TransferImportDialog.tsx new file mode 100644 index 0000000..f7799d6 --- /dev/null +++ b/resources/js/Components/Transfer/TransferImportDialog.tsx @@ -0,0 +1,145 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/Components/ui/dialog"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { Label } from "@/Components/ui/label"; +import { Upload, Download, FileSpreadsheet, AlertCircle, Info } from "lucide-react"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/Components/ui/accordion"; +import { useForm, router } from "@inertiajs/react"; +import { Alert, AlertDescription } from "@/Components/ui/alert"; + +interface TransferImportDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + orderId: number; +} + +export default function TransferImportDialog({ open, onOpenChange, orderId }: TransferImportDialogProps) { + const { data, setData, post, processing, errors, reset, clearErrors } = useForm<{ + file: File | null; + }>({ + file: null, + }); + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + setData("file", e.target.files[0]); + clearErrors("file"); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + post(route("inventory.transfer.import-items", orderId), { + forceFormData: true, + onSuccess: () => { + reset(); + onOpenChange(false); + router.reload(); + }, + }); + }; + + const handleDownloadTemplate = () => { + window.location.href = route('inventory.transfer.template'); + }; + + return ( + + + + 匯入調撥明細 + + 請先下載範本,填寫後上傳。系統將自動附加明細至本調撥單。 + + + +
+ {/* 步驟 1: 下載範本 */} +
+ +
+ 下載標準範本以確保資料格式正確。請勿修改欄位名稱。 +
+ +
+ + {/* 步驟 2: 上傳檔案 */} +
+ + +
+ +
+ {errors.file && ( + + + + {errors.file} + + + )} +
+ + {/* 欄位說明 */} + + + +
+ + 欄位填寫規則 +
+
+ +
+
    +
  • 商品代碼:必填,請填寫系統中已存在的商品代號。
  • +
  • 數量:必填,必須為大於 0 的數字。
  • +
  • 批號:選填,若不填寫將自動對應「無批號 (NO-BATCH)」的庫存。
  • +
  • 備註:選填,可填寫該筆明細的備註說明。
  • +
  • 附加模式:匯入的明細將附加至現有明細,不會覆蓋原有資料。
  • +
+
+
+
+
+ + + + + +
+
+
+ ); +} diff --git a/resources/js/Pages/Inventory/Transfer/Show.tsx b/resources/js/Pages/Inventory/Transfer/Show.tsx index 0c5d64d..12faf29 100644 --- a/resources/js/Pages/Inventory/Transfer/Show.tsx +++ b/resources/js/Pages/Inventory/Transfer/Show.tsx @@ -37,6 +37,7 @@ import { toast } from "sonner"; import axios from "axios"; import { Can } from '@/Components/Permission/Can'; import { usePermission } from '@/hooks/usePermission'; +import TransferImportDialog from '@/Components/Transfer/TransferImportDialog'; export default function Show({ order }: any) { const { can } = usePermission(); @@ -45,6 +46,15 @@ export default function Show({ order }: any) { const [isSaving, setIsSaving] = useState(false); const [deleteId, setDeleteId] = useState(null); const [isPostDialogOpen, setIsPostDialogOpen] = useState(false); + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); + + // 當 order prop 變動時 (例如匯入後 router.reload),同步更新內部狀態 + useEffect(() => { + if (order) { + setItems(order.items || []); + setRemarks(order.remarks || ""); + } + }, [order]); // Product Selection const [isProductDialogOpen, setIsProductDialogOpen] = useState(false); @@ -105,26 +115,18 @@ export default function Show({ order }: any) { availableInventory.forEach(inv => { const key = `${inv.product_id}-${inv.batch_number}`; if (selectedInventory.includes(key)) { - // Check if already added - const exists = newItems.find((i: any) => - i.product_id === inv.product_id && - i.batch_number === inv.batch_number - ); - - if (!exists) { - 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, // Default 1 - max_quantity: inv.quantity, // Max available - notes: "", - }); - addedCount++; - } + 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, // Default 1 + max_quantity: inv.quantity, // Max available + notes: "", + }); + addedCount++; } }); @@ -133,8 +135,6 @@ export default function Show({ order }: any) { if (addedCount > 0) { toast.success(`已成功加入 ${addedCount} 個項目`); - } else { - toast.info("選取的商品已在清單中"); } }; @@ -170,6 +170,11 @@ export default function Show({ order }: any) { }, { onSuccess: () => { setIsPostDialogOpen(false); + }, + onError: (errors) => { + const message = Object.values(errors).join('\n') || "過帳失敗,請檢查輸入或庫存狀態"; + toast.error(message); + setIsPostDialogOpen(false); } }); }; @@ -184,6 +189,7 @@ export default function Show({ order }: any) { const canEdit = can('inventory_transfer.edit'); const isReadOnly = order.status !== 'draft' || !canEdit; + const isVending = order.to_warehouse_type === 'vending'; return ( ) : ( setRemarks(e.target.value)} className="h-9 focus:ring-primary-main" placeholder="填寫調撥單備註..." @@ -329,145 +335,157 @@ export default function Show({ order }: any) {

{!isReadOnly && ( - - - - - - - 選擇來源庫存 ({order.from_warehouse_name}) -
- - setSearchQuery(e.target.value)} - /> -
-
-
- {loadingInventory ? ( -
- -

庫存資料載入中...

+
+ + + + + + + + + + 選擇來源庫存 ({order.from_warehouse_name}) +
+ + setSearchQuery(e.target.value)} + />
- ) : ( -
- - - - - 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()} - /> - + +
+ {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}" 相關的商品` : '尚無庫存資料'} - - + 品名 / 代號 + 批號 + 效期 + 現有庫存 + + + + {(() => { + 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())) ); - } - return filtered.map((inv) => { - const key = `${inv.product_id}-${inv.batch_number}`; - const isSelected = selectedInventory.includes(key); - return ( - toggleSelect(key)} - > - e.stopPropagation()}> - toggleSelect(key)} - /> - + if (filtered.length === 0) { + return ( + + + {searchQuery ? `找不到與 "${searchQuery}" 相關的商品` : '尚無庫存資料'} + + + ); + } - -
- {inv.product_name} - {inv.product_code} -
-
- {inv.batch_number || '-'} - {inv.expiry_date || '-'} - {inv.quantity} {inv.unit_name} -
- ); - }); - })()} -
-
-
- )} -
-
-
-
- 已選取 {selectedInventory.length} 項商品 -
- {selectedInventory.length > 0 && ( - + 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 && ( + + )} +
+
+ + +
-
- -
+ + + )} @@ -483,6 +501,7 @@ export default function Show({ order }: any) { 調撥數量 單位 + {isVending && 貨道} 備註 {!isReadOnly && } @@ -490,7 +509,7 @@ export default function Show({ order }: any) { {items.length === 0 ? ( - + 尚未加入商品 @@ -524,7 +543,7 @@ export default function Show({ order }: any) { type="number" min="0.01" step="any" - value={item.quantity} + value={item.quantity ?? ""} onChange={(e) => handleUpdateItem(index, 'quantity', e.target.value)} className="h-9 w-32 font-medium focus:ring-primary-main text-right" /> @@ -532,12 +551,26 @@ export default function Show({ order }: any) { )} {item.unit || item.unit_name} + {isVending && ( + + {isReadOnly ? ( + {item.position} + ) : ( + handleUpdateItem(index, 'position', e.target.value)} + placeholder="貨道..." + className="h-9 w-24 text-sm font-medium" + /> + )} + + )} {isReadOnly ? ( {item.notes} ) : ( handleUpdateItem(index, 'notes', e.target.value)} placeholder="備註..." className="h-9 text-sm" diff --git a/tests/Feature/InventoryTransferImportTest.php b/tests/Feature/InventoryTransferImportTest.php new file mode 100644 index 0000000..acb95ae --- /dev/null +++ b/tests/Feature/InventoryTransferImportTest.php @@ -0,0 +1,103 @@ +user = User::create([ + 'name' => 'Test User', + 'username' => 'testuser', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + $this->actingAs($this->user); + + $this->fromWarehouse = Warehouse::create([ + 'code' => 'W1', + 'name' => 'From Warehouse', + 'type' => 'standard', + ]); + $this->toWarehouse = Warehouse::create([ + 'code' => 'W2', + 'name' => 'To Warehouse', + 'type' => 'standard', + ]); + + $this->order = InventoryTransferOrder::create([ + 'doc_no' => 'TO' . time(), + 'from_warehouse_id' => $this->fromWarehouse->id, + 'to_warehouse_id' => $this->toWarehouse->id, + 'status' => 'draft', + 'created_by' => $this->user->id, + ]); + + $this->product = Product::create([ + 'code' => 'P001', + 'name' => 'Test Product', + 'status' => 'enabled', + ]); + } + + /** @test */ + public function it_can_import_items_with_chinese_headers() + { + // 建立假 Excel,使用中文標題 + $content = [ + ['商品代碼', '批號', '數量', '備註'], + ['P001', 'BATCH001', '10', 'Imported Via Test'], + ['P001', '', '5', 'Batch should be NO-BATCH'], + ]; + + // 這裡我們直接呼叫 Import 類別來測試,避免多層模擬 + $import = new InventoryTransferItemImport($this->order); + + // 我們模擬 Maatwebsite\Excel 傳入的 Collection + // 注意:Excel 預設會將標題 slugify。如果 "商品代碼" 被 slugify,我們的 Import 類別會在那邊掛掉。 + // 所以這個測試可以幫我們確認 keys 是否如預期。 + + // 如果 WithHeadingRow 是用 slug 處理,那 keys 會是 slug 化的版本。 + // 但如果我們在 Import 類別中直接讀取 $row['商品代碼'],我們得確定它真的在那裡。 + + $rows = collect([ + collect(['商品代碼' => 'P001', '批號' => 'BATCH001', '數量' => '10', '備註' => 'Imported Via Test']), + collect(['商品代碼' => 'P001', '批號' => '', '數量' => '5', '備註' => 'Batch should be NO-BATCH']), + ]); + + $import->collection($rows); + + $this->assertDatabaseHas('inventory_transfer_items', [ + 'transfer_order_id' => $this->order->id, + 'product_id' => $this->product->id, + 'batch_number' => 'BATCH001', + 'quantity' => 10, + ]); + + $this->assertDatabaseHas('inventory_transfer_items', [ + 'transfer_order_id' => $this->order->id, + 'product_id' => $this->product->id, + 'batch_number' => 'NO-BATCH', + 'quantity' => 5, + ]); + } +}