From 613eb555ba490d3fec83f36fcde534725f3bef7c Mon Sep 17 00:00:00 2001
From: sky121113
Date: Mon, 9 Feb 2026 16:52:35 +0800
Subject: [PATCH] =?UTF-8?q?feat(inventory):=20=E5=BC=B7=E5=8C=96=E8=AA=BF?=
=?UTF-8?q?=E6=92=A5=E5=96=AE=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8F=B4?=
=?UTF-8?q?=E8=B2=A9=E8=B3=A3=E6=A9=9F=E8=B2=A8=E9=81=93=E6=AC=84=E4=BD=8D?=
=?UTF-8?q?=E3=80=81=E9=96=8B=E6=94=BE=E5=95=86=E5=93=81=E9=87=8D=E8=A4=87?=
=?UTF-8?q?=E5=8A=A0=E5=85=A5=E5=8F=8A=E5=84=AA=E5=8C=96=E9=81=8E=E5=B8=B3?=
=?UTF-8?q?=E5=BA=AB=E5=AD=98=E6=AA=A2=E6=A0=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controllers/TransferOrderController.php | 61 ++-
.../InventoryTransferTemplateExport.php | 81 ++++
.../Imports/InventoryTransferItemImport.php | 131 +++++++
.../Models/InventoryTransferItem.php | 1 +
app/Modules/Inventory/Routes/web.php | 10 +
.../Inventory/Services/TransferService.php | 13 +-
...d_position_to_inventory_transfer_items.php | 28 ++
.../Transfer/TransferImportDialog.tsx | 145 ++++++++
.../js/Pages/Inventory/Transfer/Show.tsx | 347 ++++++++++--------
tests/Feature/InventoryTransferImportTest.php | 103 ++++++
10 files changed, 745 insertions(+), 175 deletions(-)
create mode 100644 app/Modules/Inventory/Exports/InventoryTransferTemplateExport.php
create mode 100644 app/Modules/Inventory/Imports/InventoryTransferItemImport.php
create mode 100644 database/migrations/tenant/2026_02_09_163350_add_position_to_inventory_transfer_items.php
create mode 100644 resources/js/Components/Transfer/TransferImportDialog.tsx
create mode 100644 tests/Feature/InventoryTransferImportTest.php
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 (
+
+ );
+}
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 && (
-