feat(inventory): 強化調撥單功能,支援販賣機貨道欄位、開放商品重複加入及優化過帳庫存檢核
This commit is contained in:
@@ -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', '只能修改草稿狀態的單據');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. 先更新資料 (如果請求中包含 items,則先執行儲存)
|
||||||
|
$itemsChanged = false;
|
||||||
|
if ($request->has('items')) {
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'items' => 'array',
|
'items' => 'array',
|
||||||
'items.*.product_id' => 'required|exists:products,id',
|
'items.*.product_id' => 'required|exists:products,id',
|
||||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||||
'items.*.batch_number' => 'nullable|string',
|
'items.*.batch_number' => 'nullable|string',
|
||||||
|
'items.*.position' => 'nullable|string',
|
||||||
'items.*.notes' => 'nullable|string',
|
'items.*.notes' => 'nullable|string',
|
||||||
'remarks' => 'nullable|string',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 1. 先更新資料
|
|
||||||
$itemsChanged = false;
|
|
||||||
if ($request->has('items')) {
|
|
||||||
$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,6 +181,8 @@ 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
'product_id',
|
||||||
'batch_number',
|
'batch_number',
|
||||||
'quantity',
|
'quantity',
|
||||||
|
'position',
|
||||||
'snapshot_quantity',
|
'snapshot_quantity',
|
||||||
'notes',
|
'notes',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 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,13 +115,6 @@ 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
|
|
||||||
const exists = newItems.find((i: any) =>
|
|
||||||
i.product_id === inv.product_id &&
|
|
||||||
i.batch_number === inv.batch_number
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
newItems.push({
|
newItems.push({
|
||||||
product_id: inv.product_id,
|
product_id: inv.product_id,
|
||||||
product_name: inv.product_name,
|
product_name: inv.product_name,
|
||||||
@@ -125,7 +128,6 @@ export default function Show({ order }: any) {
|
|||||||
});
|
});
|
||||||
addedCount++;
|
addedCount++;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setItems(newItems);
|
setItems(newItems);
|
||||||
@@ -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,6 +335,17 @@ export default function Show({ order }: any) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!isReadOnly && (
|
{!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}>
|
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className="button-outlined-primary">
|
<Button variant="outline" className="button-outlined-primary">
|
||||||
@@ -468,6 +485,7 @@ export default function Show({ order }: any) {
|
|||||||
</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"
|
||||||
|
|||||||
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