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, 'from_warehouse_name' => $order->fromWarehouse->name,
'to_warehouse_id' => (string) $order->to_warehouse_id, 'to_warehouse_id' => (string) $order->to_warehouse_id,
'to_warehouse_name' => $order->toWarehouse->name, 'to_warehouse_name' => $order->toWarehouse->name,
'to_warehouse_type' => $order->toWarehouse->type->value, // 用於判斷是否為販賣機
'status' => $order->status, 'status' => $order->status,
'remarks' => $order->remarks, 'remarks' => $order->remarks,
'created_at' => $order->created_at->format('Y-m-d H:i'), '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, 'expiry_date' => $stock && $stock->expiry_date ? $stock->expiry_date->format('Y-m-d') : null,
'unit' => $item->product->baseUnit?->name, 'unit' => $item->product->baseUnit?->name,
'quantity' => (float) $item->quantity, 'quantity' => (float) $item->quantity,
'position' => $item->position,
'max_quantity' => $item->snapshot_quantity ? (float) $item->snapshot_quantity : ($stock ? (float) $stock->quantity : 0.0), 'max_quantity' => $item->snapshot_quantity ? (float) $item->snapshot_quantity : ($stock ? (float) $stock->quantity : 0.0),
'notes' => $item->notes, 'notes' => $item->notes,
]; ];
@@ -145,31 +147,32 @@ class TransferOrderController extends Controller
return redirect()->back()->with('error', '只能修改草稿狀態的單據'); return redirect()->back()->with('error', '只能修改草稿狀態的單據');
} }
$validated = $request->validate([ // 1. 先更新資料 (如果請求中包含 items則先執行儲存)
'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. 先更新資料
$itemsChanged = false; $itemsChanged = false;
if ($request->has('items')) { 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']); $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) { if ($itemsChanged || $remarksChanged) {
$order->remarks = $validated['remarks'] ?? null;
// [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌 // [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌
$order->touch(); $order->touch();
$message = '儲存成功'; $message = '儲存成功';
} else { } else {
$message = '資料未變更'; $message = '資料未變更';
// 如果沒變更,就不執行 touch(),也不會產生 Activity Log
} }
// 2. 判斷是否需要過帳 // 2. 判斷是否需要過帳
@@ -178,8 +181,10 @@ class TransferOrderController extends Controller
$this->transferService->post($order, auth()->id()); $this->transferService->post($order, auth()->id());
return redirect()->route('inventory.transfer.index') return redirect()->route('inventory.transfer.index')
->with('success', '調撥單已過帳完成'); ->with('success', '調撥單已過帳完成');
} catch (ValidationException $e) {
return redirect()->back()->withErrors($e->errors());
} catch (\Exception $e) { } 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); 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', 'product_id',
'batch_number', 'batch_number',
'quantity', 'quantity',
'position',
'snapshot_quantity', 'snapshot_quantity',
'notes', 'notes',
]; ];

View File

@@ -112,6 +112,16 @@ Route::middleware('auth')->group(function () {
->middleware('permission:inventory.view') ->middleware('permission:inventory.view')
->name('api.warehouses.inventories'); ->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) // 進貨單 (Goods Receipts)
Route::middleware('permission:goods_receipts.view')->group(function () { Route::middleware('permission:goods_receipts.view')->group(function () {
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index'); 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'], 'product_id' => $data['product_id'],
'batch_number' => $data['batch_number'] ?? null, 'batch_number' => $data['batch_number'] ?? null,
'quantity' => $data['quantity'], 'quantity' => $data['quantity'],
'position' => $data['position'] ?? null,
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); ]);
// Eager load product for name // Eager load product for name
@@ -73,16 +74,19 @@ class TransferService
$oldItem = $oldItemsMap->get($key); $oldItem = $oldItemsMap->get($key);
// 檢查數值是否有變動 // 檢查數值是否有變動
if ((float)$oldItem->quantity !== (float)$data['quantity'] || if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
$oldItem->notes !== ($data['notes'] ?? null)) { $oldItem->notes !== ($data['notes'] ?? null) ||
$oldItem->position !== ($data['position'] ?? null)) {
$diff['updated'][] = [ $diff['updated'][] = [
'product_name' => $item->product->name, 'product_name' => $item->product->name,
'old' => [ 'old' => [
'quantity' => (float)$oldItem->quantity, 'quantity' => (float)$oldItem->quantity,
'position' => $oldItem->position,
'notes' => $oldItem->notes, 'notes' => $oldItem->notes,
], ],
'new' => [ 'new' => [
'quantity' => (float)$data['quantity'], 'quantity' => (float)$data['quantity'],
'position' => $item->position,
'notes' => $item->notes, 'notes' => $item->notes,
] ]
]; ];
@@ -148,8 +152,10 @@ class TransferService
->first(); ->first();
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) { if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
$availableQty = $sourceInventory->quantity ?? 0;
$shortageQty = $item->quantity - $availableQty;
throw ValidationException::withMessages([ 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, 'warehouse_id' => $order->to_warehouse_id,
'product_id' => $item->product_id, 'product_id' => $item->product_id,
'batch_number' => $item->batch_number, 'batch_number' => $item->batch_number,
'location' => $item->position, // 同步貨道至庫存位置
], ],
[ [
'quantity' => 0, '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 axios from "axios";
import { Can } from '@/Components/Permission/Can'; import { Can } from '@/Components/Permission/Can';
import { usePermission } from '@/hooks/usePermission'; import { usePermission } from '@/hooks/usePermission';
import TransferImportDialog from '@/Components/Transfer/TransferImportDialog';
export default function Show({ order }: any) { export default function Show({ order }: any) {
const { can } = usePermission(); const { can } = usePermission();
@@ -45,6 +46,15 @@ export default function Show({ order }: any) {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false); 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 // Product Selection
const [isProductDialogOpen, setIsProductDialogOpen] = useState(false); const [isProductDialogOpen, setIsProductDialogOpen] = useState(false);
@@ -105,26 +115,18 @@ export default function Show({ order }: any) {
availableInventory.forEach(inv => { availableInventory.forEach(inv => {
const key = `${inv.product_id}-${inv.batch_number}`; const key = `${inv.product_id}-${inv.batch_number}`;
if (selectedInventory.includes(key)) { if (selectedInventory.includes(key)) {
// Check if already added newItems.push({
const exists = newItems.find((i: any) => product_id: inv.product_id,
i.product_id === inv.product_id && product_name: inv.product_name,
i.batch_number === inv.batch_number product_code: inv.product_code,
); batch_number: inv.batch_number,
expiry_date: inv.expiry_date,
if (!exists) { unit: inv.unit_name,
newItems.push({ quantity: 1, // Default 1
product_id: inv.product_id, max_quantity: inv.quantity, // Max available
product_name: inv.product_name, notes: "",
product_code: inv.product_code, });
batch_number: inv.batch_number, addedCount++;
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) { if (addedCount > 0) {
toast.success(`已成功加入 ${addedCount} 個項目`); toast.success(`已成功加入 ${addedCount} 個項目`);
} else {
toast.info("選取的商品已在清單中");
} }
}; };
@@ -170,6 +170,11 @@ export default function Show({ order }: any) {
}, { }, {
onSuccess: () => { onSuccess: () => {
setIsPostDialogOpen(false); 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 canEdit = can('inventory_transfer.edit');
const isReadOnly = order.status !== 'draft' || !canEdit; const isReadOnly = order.status !== 'draft' || !canEdit;
const isVending = order.to_warehouse_type === 'vending';
return ( return (
<AuthenticatedLayout <AuthenticatedLayout
@@ -312,7 +318,7 @@ export default function Show({ order }: any) {
</div> </div>
) : ( ) : (
<Input <Input
value={remarks} value={remarks || ""}
onChange={(e) => setRemarks(e.target.value)} onChange={(e) => setRemarks(e.target.value)}
className="h-9 focus:ring-primary-main" className="h-9 focus:ring-primary-main"
placeholder="填寫調撥單備註..." placeholder="填寫調撥單備註..."
@@ -329,145 +335,157 @@ export default function Show({ order }: any) {
</p> </p>
</div> </div>
{!isReadOnly && ( {!isReadOnly && (
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}> <div className="flex gap-2">
<DialogTrigger asChild> <Button variant="outline" className="button-outlined-primary" onClick={() => setIsImportDialogOpen(true)}>
<Button variant="outline" className="button-outlined-primary"> <Package className="h-4 w-4 mr-2" />
<Plus className="h-4 w-4 mr-2" /> Excel
</Button>
</Button> <TransferImportDialog
</DialogTrigger> open={isImportDialogOpen}
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col p-6"> onOpenChange={setIsImportDialogOpen}
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4"> orderId={order.id}
<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" /> <Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
<Input <DialogTrigger asChild>
placeholder="搜尋品名、代號或條碼..." <Button variant="outline" className="button-outlined-primary">
className="pl-9 h-9 border-2 border-grey-3 focus:ring-primary-main" <Plus className="h-4 w-4 mr-2" />
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} </Button>
/> </DialogTrigger>
</div> <DialogContent className="max-w-4xl max-h-[85vh] flex flex-col p-6">
</DialogHeader> <DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div className="flex-1 overflow-auto pr-1"> <DialogTitle className="text-xl"> ({order.from_warehouse_name})</DialogTitle>
{loadingInventory ? ( <div className="relative w-72">
<div className="text-center py-12"> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-grey-3" />
<Package className="h-10 w-10 animate-bounce mx-auto text-gray-300 mb-2" /> <Input
<p className="text-grey-2 text-sm">...</p> 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>
) : ( </DialogHeader>
<div className="border rounded-lg overflow-hidden"> <div className="flex-1 overflow-auto pr-1">
<Table> {loadingInventory ? (
<TableHeader className="bg-gray-50/80 sticky top-0 z-10 shadow-sm"> <div className="text-center py-12">
<TableRow> <Package className="h-10 w-10 animate-bounce mx-auto text-gray-300 mb-2" />
<TableHead className="w-[50px] text-center"> <p className="text-grey-2 text-sm">...</p>
<Checkbox </div>
checked={availableInventory.length > 0 && (() => { ) : (
const filtered = availableInventory.filter(inv => <div className="border rounded-lg overflow-hidden">
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) || <Table>
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) || <TableHeader className="bg-gray-50/80 sticky top-0 z-10 shadow-sm">
(inv.product_barcode && inv.product_barcode.toLowerCase().includes(searchQuery.toLowerCase())) <TableRow>
); <TableHead className="w-[50px] text-center">
const filteredKeys = filtered.map(inv => `${inv.product_id}-${inv.batch_number}`); <Checkbox
return filteredKeys.length > 0 && filteredKeys.every(k => selectedInventory.includes(k)); checked={availableInventory.length > 0 && (() => {
})()} const filtered = availableInventory.filter(inv =>
onCheckedChange={() => toggleSelectAll()} inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
/> inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) ||
</TableHead> (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="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> <TableHead className="text-right font-medium text-grey-600 pr-6"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{(() => { {(() => {
const filtered = availableInventory.filter(inv => const filtered = availableInventory.filter(inv =>
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) || inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) || inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) ||
(inv.product_barcode && inv.product_barcode.toLowerCase().includes(searchQuery.toLowerCase())) (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>
); );
}
return filtered.map((inv) => { if (filtered.length === 0) {
const key = `${inv.product_id}-${inv.batch_number}`; return (
const isSelected = selectedInventory.includes(key); <TableRow>
return ( <TableCell colSpan={5} className="text-center py-12 text-grey-3 italic font-medium">
<TableRow {searchQuery ? `找不到與 "${searchQuery}" 相關的商品` : '尚無庫存資料'}
key={key} </TableCell>
className={`hover:bg-primary-lightest/20 cursor-pointer transition-colors ${isSelected ? 'bg-primary-lightest/40' : ''}`} </TableRow>
onClick={() => toggleSelect(key)} );
> }
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(key)}
/>
</TableCell>
<TableCell className="py-3"> return filtered.map((inv) => {
<div className="flex flex-col"> const key = `${inv.product_id}-${inv.batch_number}`;
<span className="font-semibold text-grey-0">{inv.product_name}</span> const isSelected = selectedInventory.includes(key);
<span className="text-xs text-grey-2 font-mono">{inv.product_code}</span> return (
</div> <TableRow
</TableCell> key={key}
<TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell> className={`hover:bg-primary-lightest/20 cursor-pointer transition-colors ${isSelected ? 'bg-primary-lightest/40' : ''}`}
<TableCell className="text-sm font-mono text-grey-2">{inv.expiry_date || '-'}</TableCell> onClick={() => toggleSelect(key)}
<TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell> >
</TableRow> <TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
); <Checkbox
}); checked={isSelected}
})()} onCheckedChange={() => toggleSelect(key)}
</TableBody> />
</Table> </TableCell>
</div>
)} <TableCell className="py-3">
</div> <div className="flex flex-col">
<div className="mt-6 flex items-center justify-between border-t pt-4"> <span className="font-semibold text-grey-0">{inv.product_name}</span>
<div className="flex items-center gap-3"> <span className="text-xs text-grey-2 font-mono">{inv.product_code}</span>
<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"> </div>
{selectedInventory.length} </TableCell>
</div> <TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell>
{selectedInventory.length > 0 && ( <TableCell className="text-sm font-mono text-grey-2">{inv.expiry_date || '-'}</TableCell>
<Button <TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell>
variant="ghost" </TableRow>
size="sm" );
className="text-grey-3 hover:text-red-500 hover:bg-red-50 text-xs px-2 h-7" });
onClick={() => setSelectedInventory([])} })()}
> </TableBody>
</Table>
</Button> </div>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="mt-6 flex items-center justify-between border-t pt-4">
<Button <div className="flex items-center gap-3">
variant="outline" <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">
className="button-outlined-primary w-24" {selectedInventory.length}
onClick={() => setIsProductDialogOpen(false)} </div>
> {selectedInventory.length > 0 && (
<Button
</Button> variant="ghost"
<Button size="sm"
className="button-filled-primary min-w-32" className="text-grey-3 hover:text-red-500 hover:bg-red-50 text-xs px-2 h-7"
disabled={selectedInventory.length === 0} onClick={() => setSelectedInventory([])}
onClick={handleAddSelected} >
>
</Button>
</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>
</div> </DialogContent>
</DialogContent> </Dialog>
</Dialog> </div>
)} )}
</div> </div>
@@ -483,6 +501,7 @@ export default function Show({ order }: any) {
</TableHead> </TableHead>
<TableHead className="text-right w-40 font-medium text-grey-600">調</TableHead> <TableHead className="text-right w-40 font-medium text-grey-600">調</TableHead>
<TableHead className="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> <TableHead className="font-medium text-grey-600"></TableHead>
{!isReadOnly && <TableHead className="w-[50px]"></TableHead>} {!isReadOnly && <TableHead className="w-[50px]"></TableHead>}
</TableRow> </TableRow>
@@ -490,7 +509,7 @@ export default function Show({ order }: any) {
<TableBody> <TableBody>
{items.length === 0 ? ( {items.length === 0 ? (
<TableRow> <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> </TableCell>
</TableRow> </TableRow>
@@ -524,7 +543,7 @@ export default function Show({ order }: any) {
type="number" type="number"
min="0.01" min="0.01"
step="any" step="any"
value={item.quantity} value={item.quantity ?? ""}
onChange={(e) => handleUpdateItem(index, 'quantity', e.target.value)} onChange={(e) => handleUpdateItem(index, 'quantity', e.target.value)}
className="h-9 w-32 font-medium focus:ring-primary-main text-right" 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>
<TableCell className="text-sm text-gray-500">{item.unit || item.unit_name}</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"> <TableCell className="px-1">
{isReadOnly ? ( {isReadOnly ? (
<span className="text-sm text-gray-600">{item.notes}</span> <span className="text-sm text-gray-600">{item.notes}</span>
) : ( ) : (
<Input <Input
value={item.notes} value={item.notes || ""}
onChange={(e) => handleUpdateItem(index, 'notes', e.target.value)} onChange={(e) => handleUpdateItem(index, 'notes', e.target.value)}
placeholder="備註..." placeholder="備註..."
className="h-9 text-sm" 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,
]);
}
}