feat(inventory): 強化調撥單功能,支援販賣機貨道欄位、開放商品重複加入及優化過帳庫存檢核
This commit is contained in:
@@ -108,6 +108,7 @@ class TransferOrderController extends Controller
|
||||
'from_warehouse_name' => $order->fromWarehouse->name,
|
||||
'to_warehouse_id' => (string) $order->to_warehouse_id,
|
||||
'to_warehouse_name' => $order->toWarehouse->name,
|
||||
'to_warehouse_type' => $order->toWarehouse->type->value, // 用於判斷是否為販賣機
|
||||
'status' => $order->status,
|
||||
'remarks' => $order->remarks,
|
||||
'created_at' => $order->created_at->format('Y-m-d H:i'),
|
||||
@@ -128,6 +129,7 @@ class TransferOrderController extends Controller
|
||||
'expiry_date' => $stock && $stock->expiry_date ? $stock->expiry_date->format('Y-m-d') : null,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'quantity' => (float) $item->quantity,
|
||||
'position' => $item->position,
|
||||
'max_quantity' => $item->snapshot_quantity ? (float) $item->snapshot_quantity : ($stock ? (float) $stock->quantity : 0.0),
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
@@ -145,31 +147,32 @@ class TransferOrderController extends Controller
|
||||
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'items' => 'array',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.notes' => 'nullable|string',
|
||||
'remarks' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// 1. 先更新資料
|
||||
// 1. 先更新資料 (如果請求中包含 items,則先執行儲存)
|
||||
$itemsChanged = false;
|
||||
if ($request->has('items')) {
|
||||
$validated = $request->validate([
|
||||
'items' => 'array',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.position' => 'nullable|string',
|
||||
'items.*.notes' => 'nullable|string',
|
||||
]);
|
||||
$itemsChanged = $this->transferService->updateItems($order, $validated['items']);
|
||||
}
|
||||
|
||||
$remarksChanged = $order->remarks !== ($validated['remarks'] ?? null);
|
||||
|
||||
$remarksChanged = false;
|
||||
if ($request->has('remarks')) {
|
||||
$remarksChanged = $order->remarks !== $request->input('remarks');
|
||||
$order->remarks = $request->input('remarks');
|
||||
}
|
||||
|
||||
if ($itemsChanged || $remarksChanged) {
|
||||
$order->remarks = $validated['remarks'] ?? null;
|
||||
// [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌
|
||||
$order->touch();
|
||||
$message = '儲存成功';
|
||||
} else {
|
||||
$message = '資料未變更';
|
||||
// 如果沒變更,就不執行 touch(),也不會產生 Activity Log
|
||||
}
|
||||
|
||||
// 2. 判斷是否需要過帳
|
||||
@@ -178,8 +181,10 @@ class TransferOrderController extends Controller
|
||||
$this->transferService->post($order, auth()->id());
|
||||
return redirect()->route('inventory.transfer.index')
|
||||
->with('success', '調撥單已過帳完成');
|
||||
} catch (ValidationException $e) {
|
||||
return redirect()->back()->withErrors($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
|
||||
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,4 +229,30 @@ class TransferOrderController extends Controller
|
||||
|
||||
return response()->json($inventories);
|
||||
}
|
||||
public function importItems(Request $request, InventoryTransferOrder $order)
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能在草稿狀態下匯入明細');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls,csv',
|
||||
]);
|
||||
|
||||
try {
|
||||
\Maatwebsite\Excel\Facades\Excel::import(new \App\Modules\Inventory\Imports\InventoryTransferItemImport($order), $request->file('file'));
|
||||
return redirect()->back()->with('success', '匯入成功');
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', '匯入失敗:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function template()
|
||||
{
|
||||
return \Maatwebsite\Excel\Facades\Excel::download(
|
||||
new \App\Modules\Inventory\Exports\InventoryTransferTemplateExport(),
|
||||
'調撥單明細匯入範本.xlsx'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\Exportable;
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class InventoryTransferTemplateExport implements WithMultipleSheets
|
||||
{
|
||||
use Exportable;
|
||||
|
||||
public function sheets(): array
|
||||
{
|
||||
return [
|
||||
new class implements FromCollection, WithHeadings, WithTitle, WithStyles {
|
||||
public function collection()
|
||||
{
|
||||
return collect([
|
||||
['P001', 'BATCH-2024001', '10', 'A1', '範例:請刪除此列後填寫'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['商品代碼', '批號', '數量', '貨道/儲位', '備註'];
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return '明細匯入';
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
}
|
||||
},
|
||||
new class implements FromCollection, WithHeadings, WithTitle, WithStyles {
|
||||
public function collection()
|
||||
{
|
||||
return collect([
|
||||
['商品代碼', '必填', '請填寫系統中已存在的商品代號'],
|
||||
['數量', '必填', '必須為大於 0 的數字'],
|
||||
['批號', '選填', '若不填寫將自動對應「NO-BATCH」庫存'],
|
||||
['貨道/儲位', '選填', '主要用於目的倉庫為「販賣機」時指定貨道'],
|
||||
['備註', '選填', '可填寫該筆明細的備註說明'],
|
||||
['', '', ''],
|
||||
['提示', '附加模式', '匯入的明細將附加至現有單據,不會覆蓋原有資料'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['欄位名稱', '必要性', '說明'];
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return '匯入規則說明';
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
$sheet->getColumnDimension('A')->setWidth(15);
|
||||
$sheet->getColumnDimension('B')->setWidth(15);
|
||||
$sheet->getColumnDimension('C')->setWidth(50);
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
131
app/Modules/Inventory/Imports/InventoryTransferItemImport.php
Normal file
131
app/Modules/Inventory/Imports/InventoryTransferItemImport.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Imports;
|
||||
|
||||
use App\Modules\Inventory\Models\InventoryTransferItem;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
use Exception;
|
||||
|
||||
class InventoryTransferItemImport implements ToCollection, WithMultipleSheets
|
||||
{
|
||||
protected $transferOrder;
|
||||
|
||||
public function __construct(InventoryTransferOrder $transferOrder)
|
||||
{
|
||||
$this->transferOrder = $transferOrder;
|
||||
}
|
||||
|
||||
public function collection(Collection $rows)
|
||||
{
|
||||
if ($rows->isEmpty()) {
|
||||
throw new Exception("檔案中沒有資料。");
|
||||
}
|
||||
|
||||
// 移除標題列並解析索引
|
||||
$headerRow = $rows->shift();
|
||||
$headers = $headerRow->toArray();
|
||||
|
||||
// 建立標題對應索引 (支援中文與英文)
|
||||
$colMap = [
|
||||
'product_code' => -1,
|
||||
'batch_number' => -1,
|
||||
'quantity' => -1,
|
||||
'position' => -1,
|
||||
'notes' => -1,
|
||||
];
|
||||
|
||||
foreach ($headers as $index => $label) {
|
||||
$label = trim((string)$label);
|
||||
if (in_array($label, ['商品代碼', 'product_code', 'shang_pin_dai_ma'])) $colMap['product_code'] = $index;
|
||||
if (in_array($label, ['批號', 'batch_number', 'pi_hao'])) $colMap['batch_number'] = $index;
|
||||
if (in_array($label, ['數量', 'quantity', 'shu_liang'])) $colMap['quantity'] = $index;
|
||||
if (in_array($label, ['貨道/儲位', '貨道', 'position', 'slot', 'huo_dao'])) $colMap['position'] = $index;
|
||||
if (in_array($label, ['備註', 'notes', 'bei_zhu'])) $colMap['notes'] = $index;
|
||||
}
|
||||
|
||||
// 檢查必要欄位是否有找到
|
||||
if ($colMap['product_code'] === -1 || $colMap['quantity'] === -1) {
|
||||
$foundHeaders = implode(', ', array_filter($headers));
|
||||
throw new Exception("找不到必要的欄位「商品代碼」或「數量」。讀取到的標題為:{$foundHeaders}。請確認使用的是正確的範本。");
|
||||
}
|
||||
|
||||
// 預先載入商品 (優化效能)
|
||||
$productCodes = $rows->map(fn($row) => trim((string)($row[$colMap['product_code']] ?? '')))->filter()->unique()->toArray();
|
||||
$products = Product::whereIn('code', $productCodes)->get()->keyBy('code');
|
||||
|
||||
$newItems = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($rows as $index => $row) {
|
||||
$productCode = trim((string)($row[$colMap['product_code']] ?? ''));
|
||||
$quantity = $row[$colMap['quantity']] ?? null;
|
||||
$batchNumber = $colMap['batch_number'] !== -1 ? trim((string)($row[$colMap['batch_number']] ?? '')) : '';
|
||||
$position = $colMap['position'] !== -1 ? trim((string)($row[$colMap['position']] ?? '')) : null;
|
||||
$notes = $colMap['notes'] !== -1 ? ($row[$colMap['notes']] ?? null) : null;
|
||||
|
||||
// 跳過全空行
|
||||
if (empty($productCode) && ($quantity === null || $quantity === '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lineNum = $index + 2; // 因為 shift 過,且 Excel 從 1 開始
|
||||
|
||||
if (empty($productCode)) {
|
||||
$errors[] = "第 {$lineNum} 行:商品代碼不能為空";
|
||||
continue;
|
||||
}
|
||||
|
||||
$product = $products->get($productCode);
|
||||
if (!$product) {
|
||||
$errors[] = "第 {$lineNum} 行:找不到商品代碼 '{$productCode}'";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_numeric($quantity) || (float)$quantity <= 0) {
|
||||
$errors[] = "第 {$lineNum} 行:數量必須為大於 0 的數字 (目前值: " . ($quantity ?? '空') . ")";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($batchNumber)) {
|
||||
$batchNumber = 'NO-BATCH';
|
||||
}
|
||||
|
||||
$newItems[] = [
|
||||
'transfer_order_id' => $this->transferOrder->id,
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => $batchNumber,
|
||||
'quantity' => (float)$quantity,
|
||||
'position' => $position,
|
||||
'notes' => $notes,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if (count($errors) > 0) {
|
||||
throw new Exception(implode("\n", $errors));
|
||||
}
|
||||
|
||||
if (count($newItems) === 0) {
|
||||
throw new Exception("檔案中沒有可匯入的有效資料。");
|
||||
}
|
||||
|
||||
InventoryTransferItem::insert($newItems);
|
||||
$this->transferOrder->touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定只匯入第一個分頁 (明細匯入)
|
||||
*/
|
||||
public function sheets(): array
|
||||
{
|
||||
return [
|
||||
0 => $this,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class InventoryTransferItem extends Model
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'quantity',
|
||||
'position',
|
||||
'snapshot_quantity',
|
||||
'notes',
|
||||
];
|
||||
|
||||
@@ -112,6 +112,16 @@ Route::middleware('auth')->group(function () {
|
||||
->middleware('permission:inventory.view')
|
||||
->name('api.warehouses.inventories');
|
||||
|
||||
// 調撥單匯入明細
|
||||
Route::post('/inventory/transfer-orders/{order}/import', [TransferOrderController::class, 'importItems'])
|
||||
->middleware('permission:inventory_transfer.edit')
|
||||
->name('inventory.transfer.import-items');
|
||||
|
||||
// 下載調撥單匯入範本
|
||||
Route::get('/inventory/transfer-orders/template/download', [TransferOrderController::class, 'template'])
|
||||
->middleware('permission:inventory_transfer.view')
|
||||
->name('inventory.transfer.template');
|
||||
|
||||
// 進貨單 (Goods Receipts)
|
||||
Route::middleware('permission:goods_receipts.view')->group(function () {
|
||||
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index');
|
||||
|
||||
@@ -63,6 +63,7 @@ class TransferService
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'quantity' => $data['quantity'],
|
||||
'position' => $data['position'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
// Eager load product for name
|
||||
@@ -72,17 +73,20 @@ class TransferService
|
||||
if ($oldItemsMap->has($key)) {
|
||||
$oldItem = $oldItemsMap->get($key);
|
||||
// 檢查數值是否有變動
|
||||
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
|
||||
$oldItem->notes !== ($data['notes'] ?? null)) {
|
||||
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
|
||||
$oldItem->notes !== ($data['notes'] ?? null) ||
|
||||
$oldItem->position !== ($data['position'] ?? null)) {
|
||||
|
||||
$diff['updated'][] = [
|
||||
'product_name' => $item->product->name,
|
||||
'old' => [
|
||||
'quantity' => (float)$oldItem->quantity,
|
||||
'position' => $oldItem->position,
|
||||
'notes' => $oldItem->notes,
|
||||
],
|
||||
'new' => [
|
||||
'quantity' => (float)$data['quantity'],
|
||||
'position' => $item->position,
|
||||
'notes' => $item->notes,
|
||||
]
|
||||
];
|
||||
@@ -148,8 +152,10 @@ class TransferService
|
||||
->first();
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
|
||||
$availableQty = $sourceInventory->quantity ?? 0;
|
||||
$shortageQty = $item->quantity - $availableQty;
|
||||
throw ValidationException::withMessages([
|
||||
'items' => ["商品 {$item->product->name} (批號: {$item->batch_number}) 在來源倉庫存不足"],
|
||||
'items' => ["商品 {$item->product->name} (批號: {$item->batch_number}) 在來源倉庫存不足。現有庫存:{$availableQty},尚欠:{$shortageQty}。"],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -182,6 +188,7 @@ class TransferService
|
||||
'warehouse_id' => $order->to_warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
'location' => $item->position, // 同步貨道至庫存位置
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
|
||||
Reference in New Issue
Block a user