feat(inventory): 強化調撥單功能,支援販賣機貨道欄位、開放商品重複加入及優化過帳庫存檢核
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 59s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

This commit is contained in:
2026-02-09 16:52:35 +08:00
parent 65eb1a1b64
commit 613eb555ba
10 changed files with 745 additions and 175 deletions

View File

@@ -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', '只能修改草稿狀態的單據');
}
// 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',
'remarks' => 'nullable|string',
]);
// 1. 先更新資料
$itemsChanged = false;
if ($request->has('items')) {
$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,6 +181,8 @@ 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()]);
}
@@ -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'
);
}
}

View File

@@ -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]],
];
}
},
];
}
}

View 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,
];
}
}

View File

@@ -15,6 +15,7 @@ class InventoryTransferItem extends Model
'product_id',
'batch_number',
'quantity',
'position',
'snapshot_quantity',
'notes',
];

View File

@@ -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');

View File

@@ -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
@@ -73,16 +74,19 @@ class TransferService
$oldItem = $oldItemsMap->get($key);
// 檢查數值是否有變動
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
$oldItem->notes !== ($data['notes'] ?? null)) {
$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,

View File

@@ -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');
});
}
};

View 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>
);
}

View File

@@ -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,13 +115,6 @@ 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,
@@ -125,7 +128,6 @@ export default function Show({ order }: any) {
});
addedCount++;
}
}
});
setItems(newItems);
@@ -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,6 +335,17 @@ export default function Show({ order }: any) {
</p>
</div>
{!isReadOnly && (
<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">
@@ -468,6 +485,7 @@ export default function Show({ order }: any) {
</div>
</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"

View 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,
]);
}
}