feat(inventory): 強化調撥單功能,支援販賣機貨道欄位、開放商品重複加入及優化過帳庫存檢核
This commit is contained in:
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\Exportable;
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class InventoryTransferTemplateExport implements WithMultipleSheets
|
||||
{
|
||||
use Exportable;
|
||||
|
||||
public function sheets(): array
|
||||
{
|
||||
return [
|
||||
new class implements FromCollection, WithHeadings, WithTitle, WithStyles {
|
||||
public function collection()
|
||||
{
|
||||
return collect([
|
||||
['P001', 'BATCH-2024001', '10', 'A1', '範例:請刪除此列後填寫'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['商品代碼', '批號', '數量', '貨道/儲位', '備註'];
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return '明細匯入';
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
1 => ['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]],
|
||||
];
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
131
app/Modules/Inventory/Imports/InventoryTransferItemImport.php
Normal file
131
app/Modules/Inventory/Imports/InventoryTransferItemImport.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Imports;
|
||||
|
||||
use App\Modules\Inventory\Models\InventoryTransferItem;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
use Exception;
|
||||
|
||||
class InventoryTransferItemImport implements ToCollection, WithMultipleSheets
|
||||
{
|
||||
protected $transferOrder;
|
||||
|
||||
public function __construct(InventoryTransferOrder $transferOrder)
|
||||
{
|
||||
$this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class InventoryTransferItem extends Model
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'quantity',
|
||||
'position',
|
||||
'snapshot_quantity',
|
||||
'notes',
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('inventory_transfer_items', function (Blueprint $table) {
|
||||
$table->string('position')->nullable()->after('quantity')->comment('貨道/儲位');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('inventory_transfer_items', function (Blueprint $table) {
|
||||
$table->dropColumn('position');
|
||||
});
|
||||
}
|
||||
};
|
||||
145
resources/js/Components/Transfer/TransferImportDialog.tsx
Normal file
145
resources/js/Components/Transfer/TransferImportDialog.tsx
Normal file
@@ -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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>匯入調撥明細</DialogTitle>
|
||||
<DialogDescription>
|
||||
請先下載範本,填寫後上傳。系統將自動附加明細至本調撥單。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 步驟 1: 下載範本 */}
|
||||
<div className="space-y-2 p-4 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<Label className="font-medium flex items-center gap-2">
|
||||
<FileSpreadsheet className="w-4 h-4 text-green-600" />
|
||||
步驟 1:取得 CSV 範本
|
||||
</Label>
|
||||
<div className="text-sm text-gray-500 mb-2">
|
||||
下載標準範本以確保資料格式正確。請勿修改欄位名稱。
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadTemplate}
|
||||
className="w-full sm:w-auto button-outlined-primary"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
下載範本 (.xlsx)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 步驟 2: 上傳檔案 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium flex items-center gap-2">
|
||||
<Upload className="w-4 h-4 text-blue-600" />
|
||||
步驟 2:上傳填寫後的檔案
|
||||
</Label>
|
||||
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".xlsx, .xls, .csv"
|
||||
onChange={handleFileChange}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{errors.file && (
|
||||
<Alert variant="destructive" className="mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="whitespace-pre-wrap">
|
||||
{errors.file}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 欄位說明 */}
|
||||
<Accordion type="single" collapsible className="w-full border rounded-lg px-2">
|
||||
<AccordionItem value="item-1" className="border-b-0">
|
||||
<AccordionTrigger className="text-sm text-gray-500 hover:no-underline py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
欄位填寫規則
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="text-sm text-gray-600 space-y-2 pb-2 pl-6">
|
||||
<ul className="list-disc space-y-1">
|
||||
<li><span className="font-medium text-gray-700">商品代碼</span>:必填,請填寫系統中已存在的商品代號。</li>
|
||||
<li><span className="font-medium text-gray-700">數量</span>:必填,必須為大於 0 的數字。</li>
|
||||
<li><span className="font-medium text-gray-700">批號</span>:選填,若不填寫將自動對應「無批號 (NO-BATCH)」的庫存。</li>
|
||||
<li><span className="font-medium text-gray-700">備註</span>:選填,可填寫該筆明細的備註說明。</li>
|
||||
<li><span className="font-medium text-gray-700">附加模式</span>:匯入的明細將附加至現有明細,不會覆蓋原有資料。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={processing}
|
||||
className="button-outlined-primary"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={!data.file || processing} className="button-filled-primary">
|
||||
{processing ? "匯入中..." : "開始匯入"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<AuthenticatedLayout
|
||||
@@ -312,7 +318,7 @@ export default function Show({ order }: any) {
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={remarks}
|
||||
value={remarks || ""}
|
||||
onChange={(e) => setRemarks(e.target.value)}
|
||||
className="h-9 focus:ring-primary-main"
|
||||
placeholder="填寫調撥單備註..."
|
||||
@@ -329,145 +335,157 @@ export default function Show({ order }: any) {
|
||||
</p>
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="button-outlined-primary">
|
||||
<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 className="flex gap-2">
|
||||
<Button variant="outline" className="button-outlined-primary" onClick={() => setIsImportDialogOpen(true)}>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
匯入 Excel
|
||||
</Button>
|
||||
<TransferImportDialog
|
||||
open={isImportDialogOpen}
|
||||
onOpenChange={setIsImportDialogOpen}
|
||||
orderId={order.id}
|
||||
/>
|
||||
|
||||
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="button-outlined-primary">
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
</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>
|
||||
<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()))
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12 text-grey-3 italic font-medium">
|
||||
{searchQuery ? `找不到與 "${searchQuery}" 相關的商品` : '尚無庫存資料'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
<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>
|
||||
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="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 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>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -483,6 +501,7 @@ export default function Show({ order }: any) {
|
||||
</TableHead>
|
||||
<TableHead className="text-right w-40 font-medium text-grey-600">調撥數量</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">單位</TableHead>
|
||||
{isVending && <TableHead className="font-medium text-grey-600">貨道</TableHead>}
|
||||
<TableHead className="font-medium text-grey-600">備註</TableHead>
|
||||
{!isReadOnly && <TableHead className="w-[50px]"></TableHead>}
|
||||
</TableRow>
|
||||
@@ -490,7 +509,7 @@ export default function Show({ order }: any) {
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center h-24 text-gray-500">
|
||||
<TableCell colSpan={isVending ? 9 : 8} className="text-center h-24 text-gray-500">
|
||||
尚未加入商品
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -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) {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">{item.unit || item.unit_name}</TableCell>
|
||||
{isVending && (
|
||||
<TableCell className="px-1">
|
||||
{isReadOnly ? (
|
||||
<span className="text-sm font-medium">{item.position}</span>
|
||||
) : (
|
||||
<Input
|
||||
value={item.position || ""}
|
||||
onChange={(e) => handleUpdateItem(index, 'position', e.target.value)}
|
||||
placeholder="貨道..."
|
||||
className="h-9 w-24 text-sm font-medium"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="px-1">
|
||||
{isReadOnly ? (
|
||||
<span className="text-sm text-gray-600">{item.notes}</span>
|
||||
) : (
|
||||
<Input
|
||||
value={item.notes}
|
||||
value={item.notes || ""}
|
||||
onChange={(e) => handleUpdateItem(index, 'notes', e.target.value)}
|
||||
placeholder="備註..."
|
||||
className="h-9 text-sm"
|
||||
|
||||
103
tests/Feature/InventoryTransferImportTest.php
Normal file
103
tests/Feature/InventoryTransferImportTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Modules\Core\Models\User;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Imports\InventoryTransferItemImport;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InventoryTransferImportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $user;
|
||||
protected $fromWarehouse;
|
||||
protected $toWarehouse;
|
||||
protected $order;
|
||||
protected $product;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user