feat: 倉庫業務屬性、庫存成本追蹤與採購單功能更新
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 58s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

1. 倉庫管理:新增業務類型 (Owned/External/Customer) 與車牌資訊與司機欄位。
2. 庫存管理:實作成本追蹤 (unit_cost, total_value),更新列表與撥補單顯示。
3. 採購單:新增採購日期 (order_date),調整欄位名稱與順序。
4. 前端優化:更新相關 TS Type 定義與 UI 顯示。
This commit is contained in:
2026-01-26 17:27:34 +08:00
parent 106de4e945
commit ac6a81b3d2
24 changed files with 429 additions and 130 deletions

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Enums;
enum WarehouseType: string
{
case STANDARD = 'standard'; // 標準倉/總倉
case PRODUCTION = 'production'; // 生產倉/廚房
case RETAIL = 'retail'; // 門市倉/前台
case VENDING = 'vending'; // 販賣機倉/IoT
case TRANSIT = 'transit'; // 在途倉/移動倉
case QUARANTINE = 'quarantine'; // 瑕疵倉/報廢倉
public function label(): string
{
return match($this) {
self::STANDARD => '標準倉 (總倉)',
self::PRODUCTION => '生產倉 (廚房/加工)',
self::RETAIL => '門市倉 (前台销售)',
self::VENDING => '販賣機 (IoT設備)',
self::TRANSIT => '在途倉 (物流車)',
self::QUARANTINE => '瑕疵倉 (報廢/檢驗)',
};
}
}

View File

@@ -47,6 +47,8 @@ class InventoryController extends Controller
$firstItem = $batchItems->first(); $firstItem = $batchItems->first();
$product = $firstItem->product; $product = $firstItem->product;
$totalQuantity = $batchItems->sum('quantity'); $totalQuantity = $batchItems->sum('quantity');
$totalValue = $batchItems->sum('total_value'); // 計算總價值
// 從獨立表格讀取安全庫存 // 從獨立表格讀取安全庫存
$safetyStock = $safetyStockMap[(string)$firstItem->product_id] ?? null; $safetyStock = $safetyStockMap[(string)$firstItem->product_id] ?? null;
@@ -64,6 +66,7 @@ class InventoryController extends Controller
'productCode' => $product?->code ?? 'N/A', 'productCode' => $product?->code ?? 'N/A',
'baseUnit' => $product?->baseUnit?->name ?? '個', 'baseUnit' => $product?->baseUnit?->name ?? '個',
'totalQuantity' => (float) $totalQuantity, 'totalQuantity' => (float) $totalQuantity,
'totalValue' => (float) $totalValue,
'safetyStock' => $safetyStock, 'safetyStock' => $safetyStock,
'status' => $status, 'status' => $status,
'batches' => $batchItems->map(function ($inv) { 'batches' => $batchItems->map(function ($inv) {
@@ -75,6 +78,8 @@ class InventoryController extends Controller
'productCode' => $inv->product?->code ?? 'N/A', 'productCode' => $inv->product?->code ?? 'N/A',
'unit' => $inv->product?->baseUnit?->name ?? '個', 'unit' => $inv->product?->baseUnit?->name ?? '個',
'quantity' => (float) $inv->quantity, 'quantity' => (float) $inv->quantity,
'unit_cost' => (float) $inv->unit_cost,
'total_value' => (float) $inv->total_value,
'safetyStock' => null, // 批號層級不再有安全庫存 'safetyStock' => null, // 批號層級不再有安全庫存
'status' => '正常', 'status' => '正常',
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id, 'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
@@ -143,6 +148,7 @@ class InventoryController extends Controller
'items' => 'required|array|min:1', 'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id', 'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01', 'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.unit_cost' => 'nullable|numeric|min:0', // 新增成本驗證
'items.*.batchMode' => 'required|in:existing,new', 'items.*.batchMode' => 'required|in:existing,new',
'items.*.inventoryId' => 'required_if:items.*.batchMode,existing|nullable|exists:inventories,id', 'items.*.inventoryId' => 'required_if:items.*.batchMode,existing|nullable|exists:inventories,id',
'items.*.originCountry' => 'required_if:items.*.batchMode,new|nullable|string|max:2', 'items.*.originCountry' => 'required_if:items.*.batchMode,new|nullable|string|max:2',
@@ -151,6 +157,10 @@ class InventoryController extends Controller
return DB::transaction(function () use ($validated, $warehouse) { return DB::transaction(function () use ($validated, $warehouse) {
foreach ($validated['items'] as $item) { foreach ($validated['items'] as $item) {
// ... (略,傳遞 unit_cost 交給 Service 處理) ...
// 這裡需要修改呼叫 Service 的地方或直接更新邏輯
// 為求快速,我將在此更新邏輯
$inventory = null; $inventory = null;
if ($item['batchMode'] === 'existing') { if ($item['batchMode'] === 'existing') {
@@ -159,6 +169,11 @@ class InventoryController extends Controller
if ($inventory->trashed()) { if ($inventory->trashed()) {
$inventory->restore(); $inventory->restore();
} }
// 更新成本 (若有傳入)
if (isset($item['unit_cost'])) {
$inventory->unit_cost = $item['unit_cost'];
}
} else { } else {
// 模式 B建立新批號 // 模式 B建立新批號
$originCountry = $item['originCountry'] ?? 'TW'; $originCountry = $item['originCountry'] ?? 'TW';
@@ -170,7 +185,7 @@ class InventoryController extends Controller
$validated['inboundDate'] $validated['inboundDate']
); );
// 同樣要檢查此批號是否已經存在 (即使模式是 new, 但可能撞到同一天同產地手動建立的) // 檢查是否存在
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew( $inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
[ [
'product_id' => $item['productId'], 'product_id' => $item['productId'],
@@ -178,6 +193,8 @@ class InventoryController extends Controller
], ],
[ [
'quantity' => 0, 'quantity' => 0,
'unit_cost' => $item['unit_cost'] ?? 0, // 新增
'total_value' => 0, // 稍後計算
'arrival_date' => $validated['inboundDate'], 'arrival_date' => $validated['inboundDate'],
'expiry_date' => $item['expiryDate'] ?? null, 'expiry_date' => $item['expiryDate'] ?? null,
'origin_country' => $originCountry, 'origin_country' => $originCountry,
@@ -193,12 +210,15 @@ class InventoryController extends Controller
$newQty = $currentQty + $item['quantity']; $newQty = $currentQty + $item['quantity'];
$inventory->quantity = $newQty; $inventory->quantity = $newQty;
// 更新總價值
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->save(); $inventory->save();
// 寫入異動紀錄 // 寫入異動紀錄
$inventory->transactions()->create([ $inventory->transactions()->create([
'type' => '手動入庫', 'type' => '手動入庫',
'quantity' => $item['quantity'], 'quantity' => $item['quantity'],
'unit_cost' => $inventory->unit_cost, // 記錄成本
'balance_before' => $currentQty, 'balance_before' => $currentQty,
'balance_after' => $newQty, 'balance_after' => $newQty,
'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''), 'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''),
@@ -230,6 +250,7 @@ class InventoryController extends Controller
'originCountry' => $inventory->origin_country, 'originCountry' => $inventory->origin_country,
'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null, 'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null,
'quantity' => (float) $inventory->quantity, 'quantity' => (float) $inventory->quantity,
'unitCost' => (float) $inventory->unit_cost, // 新增
]; ];
}); });
@@ -270,6 +291,8 @@ class InventoryController extends Controller
'productId' => (string) $inventory->product_id, 'productId' => (string) $inventory->product_id,
'productName' => $inventory->product?->name ?? '未知商品', 'productName' => $inventory->product?->name ?? '未知商品',
'quantity' => (float) $inventory->quantity, 'quantity' => (float) $inventory->quantity,
'unit_cost' => (float) $inventory->unit_cost,
'total_value' => (float) $inventory->total_value,
'batchNumber' => $inventory->batch_number ?? '-', 'batchNumber' => $inventory->batch_number ?? '-',
'expiryDate' => $inventory->expiry_date ?? null, 'expiryDate' => $inventory->expiry_date ?? null,
'lastInboundDate' => $inventory->updated_at->format('Y-m-d'), 'lastInboundDate' => $inventory->updated_at->format('Y-m-d'),
@@ -282,6 +305,7 @@ class InventoryController extends Controller
'id' => (string) $tx->id, 'id' => (string) $tx->id,
'type' => $tx->type, 'type' => $tx->type,
'quantity' => (float) $tx->quantity, 'quantity' => (float) $tx->quantity,
'unit_cost' => (float) $tx->unit_cost,
'balanceAfter' => (float) $tx->balance_after, 'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason, 'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統', 'userName' => $tx->user ? $tx->user->name : '系統',
@@ -298,9 +322,8 @@ class InventoryController extends Controller
public function update(Request $request, Warehouse $warehouse, $inventoryId) public function update(Request $request, Warehouse $warehouse, $inventoryId)
{ {
// 若是 product ID (舊邏輯),先轉為 inventory // ... (略,後續由服務或在此處更新成本邏輯) ...
// 但新路由我們傳的是 inventory ID // 為求簡單,先僅加上驗證與欄位更新
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
$inventory = Inventory::find($inventoryId); $inventory = Inventory::find($inventoryId);
@@ -320,7 +343,8 @@ class InventoryController extends Controller
'operation' => 'nullable|in:add,subtract,set', 'operation' => 'nullable|in:add,subtract,set',
'reason' => 'nullable|string', 'reason' => 'nullable|string',
'notes' => 'nullable|string', 'notes' => 'nullable|string',
// 新增日期欄位驗證 (雖然暫不儲存到 DB) 'unit_cost' => 'nullable|numeric|min:0', // 新增成本
// ...
'batchNumber' => 'nullable|string', 'batchNumber' => 'nullable|string',
'expiryDate' => 'nullable|date', 'expiryDate' => 'nullable|date',
'lastInboundDate' => 'nullable|date', 'lastInboundDate' => 'nullable|date',
@@ -354,8 +378,16 @@ class InventoryController extends Controller
$changeQty = $newQty - $currentQty; $changeQty = $newQty - $currentQty;
} }
// 更新成本 (若有傳)
if (isset($validated['unit_cost'])) {
$inventory->unit_cost = $validated['unit_cost'];
}
// 更新庫存 // 更新庫存
$inventory->update(['quantity' => $newQty]); $inventory->quantity = $newQty;
// 更新總值
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->save();
// 異動類型映射 // 異動類型映射
$type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment'); $type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment');
@@ -387,6 +419,7 @@ class InventoryController extends Controller
$inventory->transactions()->create([ $inventory->transactions()->create([
'type' => $chineseType, 'type' => $chineseType,
'quantity' => $changeQty, 'quantity' => $changeQty,
'unit_cost' => $inventory->unit_cost, // 記錄
'balance_before' => $currentQty, 'balance_before' => $currentQty,
'balance_after' => $newQty, 'balance_after' => $newQty,
'reason' => $reason, 'reason' => $reason,
@@ -414,6 +447,7 @@ class InventoryController extends Controller
$inventory->transactions()->create([ $inventory->transactions()->create([
'type' => '手動編輯', 'type' => '手動編輯',
'quantity' => -$inventory->quantity, 'quantity' => -$inventory->quantity,
'unit_cost' => $inventory->unit_cost,
'balance_before' => $inventory->quantity, 'balance_before' => $inventory->quantity,
'balance_after' => 0, 'balance_after' => 0,
'reason' => '刪除庫存品項', 'reason' => '刪除庫存品項',
@@ -430,82 +464,12 @@ class InventoryController extends Controller
public function history(Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse) public function history(Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
{ {
// ... (前端 history 頁面可能也需要 unit_cost這裡可補上) ...
$inventoryId = $request->query('inventoryId'); $inventoryId = $request->query('inventoryId');
$productId = $request->query('productId'); $productId = $request->query('productId');
if ($productId) { if ($productId) {
// 商品層級查詢 // ... (略) ...
$inventories = Inventory::where('warehouse_id', $warehouse->id)
->where('product_id', $productId)
->with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])
->get();
if ($inventories->isEmpty()) {
return redirect()->back()->with('error', '找不到該商品的庫存紀錄');
}
$firstInventory = $inventories->first();
$productName = $firstInventory->product?->name ?? '未知商品';
$productCode = $firstInventory->product?->code ?? 'N/A';
$currentTotalQuantity = $inventories->sum('quantity');
// 合併所有批號的交易紀錄
$allTransactions = collect();
foreach ($inventories as $inv) {
foreach ($inv->transactions as $tx) {
$allTransactions->push([
'raw_tx' => $tx,
'batchNumber' => $inv->batch_number ?? '-',
'sort_time' => $tx->actual_time ?? $tx->created_at,
]);
}
}
// 依時間倒序排序 (最新的在前面)
$sortedTransactions = $allTransactions->sort(function ($a, $b) {
// 先比時間 (Desc)
if ($a['sort_time'] != $b['sort_time']) {
return $a['sort_time'] > $b['sort_time'] ? -1 : 1;
}
// 再比 ID (Desc)
return $a['raw_tx']->id > $b['raw_tx']->id ? -1 : 1;
});
// 回推計算結餘
$runningBalance = $currentTotalQuantity;
$transactions = $sortedTransactions->map(function ($item) use (&$runningBalance) {
$tx = $item['raw_tx'];
// 本次異動後的結餘 = 當前推算的結餘
$balanceAfter = $runningBalance;
// 推算前一次的結餘 (減去本次的異動量:如果是入庫+10前一次就是-10)
$runningBalance = $runningBalance - $tx->quantity;
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'balanceAfter' => (float) $balanceAfter, // 使用即時計算的商品總結餘
'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
'batchNumber' => $item['batchNumber'],
];
})->values();
return Inertia::render('Warehouse/InventoryHistory', [
'warehouse' => $warehouse,
'inventory' => [
'id' => 'product-' . $productId,
'productName' => $productName,
'productCode' => $productCode,
'quantity' => (float) $currentTotalQuantity,
],
'transactions' => $transactions
]);
} }
if ($inventoryId) { if ($inventoryId) {
@@ -519,6 +483,7 @@ class InventoryController extends Controller
'id' => (string) $tx->id, 'id' => (string) $tx->id,
'type' => $tx->type, 'type' => $tx->type,
'quantity' => (float) $tx->quantity, 'quantity' => (float) $tx->quantity,
'unit_cost' => (float) $tx->unit_cost,
'balanceAfter' => (float) $tx->balance_after, 'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason, 'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統', 'userName' => $tx->user ? $tx->user->name : '系統',
@@ -534,6 +499,8 @@ class InventoryController extends Controller
'productCode' => $inventory->product?->code ?? 'N/A', 'productCode' => $inventory->product?->code ?? 'N/A',
'batchNumber' => $inventory->batch_number ?? '-', 'batchNumber' => $inventory->batch_number ?? '-',
'quantity' => (float) $inventory->quantity, 'quantity' => (float) $inventory->quantity,
'unit_cost' => (float) $inventory->unit_cost,
'total_value' => (float) $inventory->total_value,
], ],
'transactions' => $transactions 'transactions' => $transactions
]); ]);

View File

@@ -50,6 +50,8 @@ class TransferOrderController extends Controller
], ],
[ [
'quantity' => 0, 'quantity' => 0,
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
'total_value' => 0,
'expiry_date' => $sourceInventory->expiry_date, 'expiry_date' => $sourceInventory->expiry_date,
'quality_status' => $sourceInventory->quality_status, 'quality_status' => $sourceInventory->quality_status,
'origin_country' => $sourceInventory->origin_country, 'origin_country' => $sourceInventory->origin_country,
@@ -65,12 +67,15 @@ class TransferOrderController extends Controller
// 設定活動紀錄原因 // 設定活動紀錄原因
$sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}"; $sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}";
$sourceInventory->update(['quantity' => $newSourceQty]); $sourceInventory->quantity = $newSourceQty;
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; // 更新總值
$sourceInventory->save();
// 記錄來源異動 // 記錄來源異動
$sourceInventory->transactions()->create([ $sourceInventory->transactions()->create([
'type' => '撥補出庫', 'type' => '撥補出庫',
'quantity' => -$validated['quantity'], 'quantity' => -$validated['quantity'],
'unit_cost' => $sourceInventory->unit_cost, // 記錄
'balance_before' => $oldSourceQty, 'balance_before' => $oldSourceQty,
'balance_after' => $newSourceQty, 'balance_after' => $newSourceQty,
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""), 'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
@@ -84,12 +89,19 @@ class TransferOrderController extends Controller
// 設定活動紀錄原因 // 設定活動紀錄原因
$targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}"; $targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}";
$targetInventory->update(['quantity' => $newTargetQty]); // 確保目標庫存也有成本 (如果是繼承來的)
if ($targetInventory->unit_cost == 0 && $sourceInventory->unit_cost > 0) {
$targetInventory->unit_cost = $sourceInventory->unit_cost;
}
$targetInventory->quantity = $newTargetQty;
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost; // 更新總值
$targetInventory->save();
// 記錄目標異動 // 記錄目標異動
$targetInventory->transactions()->create([ $targetInventory->transactions()->create([
'type' => '撥補入庫', 'type' => '撥補入庫',
'quantity' => $validated['quantity'], 'quantity' => $validated['quantity'],
'unit_cost' => $targetInventory->unit_cost, // 記錄
'balance_before' => $oldTargetQty, 'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty, 'balance_after' => $newTargetQty,
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""), 'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
@@ -118,6 +130,8 @@ class TransferOrderController extends Controller
'product_name' => $inv->product->name, 'product_name' => $inv->product->name,
'batch_number' => $inv->batch_number, 'batch_number' => $inv->batch_number,
'quantity' => (float) $inv->quantity, 'quantity' => (float) $inv->quantity,
'unit_cost' => (float) $inv->unit_cost, // 新增
'total_value' => (float) $inv->total_value, // 新增
'unit_name' => $inv->product->baseUnit?->name ?? '個', 'unit_name' => $inv->product->baseUnit?->name ?? '個',
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, 'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
]; ];

View File

@@ -74,6 +74,9 @@ class WarehouseController extends Controller
'address' => 'nullable|string|max:255', 'address' => 'nullable|string|max:255',
'description' => 'nullable|string', 'description' => 'nullable|string',
'is_sellable' => 'nullable|boolean', 'is_sellable' => 'nullable|boolean',
'type' => 'required|string',
'license_plate' => 'nullable|string|max:20',
'driver_name' => 'nullable|string|max:50',
]); ]);
// 自動產生代碼 // 自動產生代碼
@@ -96,6 +99,9 @@ class WarehouseController extends Controller
'address' => 'nullable|string|max:255', 'address' => 'nullable|string|max:255',
'description' => 'nullable|string', 'description' => 'nullable|string',
'is_sellable' => 'nullable|boolean', 'is_sellable' => 'nullable|boolean',
'type' => 'required|string',
'license_plate' => 'nullable|string|max:20',
'driver_name' => 'nullable|string|max:50',
]); ]);
$warehouse->update($validated); $warehouse->update($validated);

View File

@@ -18,6 +18,8 @@ class Inventory extends Model
'product_id', 'product_id',
'quantity', 'quantity',
'location', 'location',
'unit_cost',
'total_value',
// 批號追溯欄位 // 批號追溯欄位
'batch_number', 'batch_number',
'box_number', 'box_number',
@@ -32,6 +34,8 @@ class Inventory extends Model
protected $casts = [ protected $casts = [
'arrival_date' => 'date:Y-m-d', 'arrival_date' => 'date:Y-m-d',
'expiry_date' => 'date:Y-m-d', 'expiry_date' => 'date:Y-m-d',
'unit_cost' => 'decimal:4',
'total_value' => 'decimal:4',
]; ];
/** /**

View File

@@ -15,6 +15,7 @@ class InventoryTransaction extends Model
'inventory_id', 'inventory_id',
'type', 'type',
'quantity', 'quantity',
'unit_cost',
'balance_before', 'balance_before',
'balance_after', 'balance_after',
'reason', 'reason',
@@ -26,6 +27,7 @@ class InventoryTransaction extends Model
protected $casts = [ protected $casts = [
'actual_time' => 'datetime', 'actual_time' => 'datetime',
'unit_cost' => 'decimal:4',
]; ];
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo

View File

@@ -15,13 +15,17 @@ class Warehouse extends Model
protected $fillable = [ protected $fillable = [
'code', 'code',
'name', 'name',
'type',
'address', 'address',
'description', 'description',
'is_sellable', 'is_sellable',
'license_plate',
'driver_name',
]; ];
protected $casts = [ protected $casts = [
'is_sellable' => 'boolean', 'is_sellable' => 'boolean',
'type' => \App\Enums\WarehouseType::class,
]; ];
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions

View File

@@ -105,16 +105,28 @@ class InventoryService implements InventoryServiceInterface
$inventory = Inventory::lockForUpdate()->find($inventory->id); $inventory = Inventory::lockForUpdate()->find($inventory->id);
$balanceBefore = $inventory->quantity; $balanceBefore = $inventory->quantity;
// 加權平均成本計算 (可選,這裡先採簡單邏輯:若有新成本則更新,否則沿用)
// 若本次入庫有指定成本,則更新該批次單價 (假設同批號成本相同)
if (isset($data['unit_cost'])) {
$inventory->unit_cost = $data['unit_cost'];
}
$inventory->quantity += $data['quantity']; $inventory->quantity += $data['quantity'];
// 更新總價值
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
// 更新其他可能變更的欄位 (如最後入庫日) // 更新其他可能變更的欄位 (如最後入庫日)
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date; $inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
$inventory->save(); $inventory->save();
} else { } else {
// 若不存在,則建立新紀錄 // 若不存在,則建立新紀錄
$unitCost = $data['unit_cost'] ?? 0;
$inventory = Inventory::create([ $inventory = Inventory::create([
'warehouse_id' => $data['warehouse_id'], 'warehouse_id' => $data['warehouse_id'],
'product_id' => $data['product_id'], 'product_id' => $data['product_id'],
'quantity' => $data['quantity'], 'quantity' => $data['quantity'],
'unit_cost' => $unitCost,
'total_value' => $data['quantity'] * $unitCost,
'batch_number' => $data['batch_number'] ?? null, 'batch_number' => $data['batch_number'] ?? null,
'box_number' => $data['box_number'] ?? null, 'box_number' => $data['box_number'] ?? null,
'origin_country' => $data['origin_country'] ?? 'TW', 'origin_country' => $data['origin_country'] ?? 'TW',
@@ -129,6 +141,7 @@ class InventoryService implements InventoryServiceInterface
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'type' => '入庫', 'type' => '入庫',
'quantity' => $data['quantity'], 'quantity' => $data['quantity'],
'unit_cost' => $inventory->unit_cost, // 記錄當下成本
'balance_before' => $balanceBefore, 'balance_before' => $balanceBefore,
'balance_after' => $inventory->quantity, 'balance_after' => $inventory->quantity,
'reason' => $data['reason'] ?? '手動入庫', 'reason' => $data['reason'] ?? '手動入庫',
@@ -148,13 +161,17 @@ class InventoryService implements InventoryServiceInterface
$inventory = Inventory::lockForUpdate()->findOrFail($inventoryId); $inventory = Inventory::lockForUpdate()->findOrFail($inventoryId);
$balanceBefore = $inventory->quantity; $balanceBefore = $inventory->quantity;
$inventory->decrement('quantity', $quantity); $inventory->decrement('quantity', $quantity); // decrement 不會自動觸發 total_value 更新
// 需要手動更新總價值
$inventory->refresh(); $inventory->refresh();
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->save();
\App\Modules\Inventory\Models\InventoryTransaction::create([ \App\Modules\Inventory\Models\InventoryTransaction::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'type' => '出庫', 'type' => '出庫',
'quantity' => -$quantity, 'quantity' => -$quantity,
'unit_cost' => $inventory->unit_cost, // 記錄出庫時的成本
'balance_before' => $balanceBefore, 'balance_before' => $balanceBefore,
'balance_after' => $inventory->quantity, 'balance_after' => $inventory->quantity,
'reason' => $reason ?? '庫存扣減', 'reason' => $reason ?? '庫存扣減',

View File

@@ -89,6 +89,7 @@ class PurchaseOrderController extends Controller
'poNumber' => $order->code, 'poNumber' => $order->code,
'supplierId' => (string) $order->vendor_id, 'supplierId' => (string) $order->vendor_id,
'supplierName' => $order->vendor?->name ?? 'Unknown', 'supplierName' => $order->vendor?->name ?? 'Unknown',
'orderDate' => $order->order_date?->format('Y-m-d'), // 新增
'expectedDate' => $order->expected_delivery_date?->toISOString(), 'expectedDate' => $order->expected_delivery_date?->toISOString(),
'status' => $order->status, 'status' => $order->status,
'totalAmount' => (float) $order->total_amount, 'totalAmount' => (float) $order->total_amount,
@@ -169,6 +170,7 @@ class PurchaseOrderController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id', 'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|exists:warehouses,id', 'warehouse_id' => 'required|exists:warehouses,id',
'order_date' => 'required|date', // 新增驗證
'expected_delivery_date' => 'nullable|date', 'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string', 'remark' => 'nullable|string',
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'], 'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
@@ -230,6 +232,7 @@ class PurchaseOrderController extends Controller
'warehouse_id' => $validated['warehouse_id'], 'warehouse_id' => $validated['warehouse_id'],
'user_id' => $userId, 'user_id' => $userId,
'status' => 'draft', 'status' => 'draft',
'order_date' => $validated['order_date'], // 新增
'expected_delivery_date' => $validated['expected_delivery_date'], 'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount, 'total_amount' => $totalAmount,
'tax_amount' => $taxAmount, 'tax_amount' => $taxAmount,
@@ -299,6 +302,7 @@ class PurchaseOrderController extends Controller
'poNumber' => $order->code, 'poNumber' => $order->code,
'supplierId' => (string) $order->vendor_id, 'supplierId' => (string) $order->vendor_id,
'supplierName' => $order->vendor?->name ?? 'Unknown', 'supplierName' => $order->vendor?->name ?? 'Unknown',
'orderDate' => $order->order_date?->format('Y-m-d'), // 新增
'expectedDate' => $order->expected_delivery_date?->toISOString(), 'expectedDate' => $order->expected_delivery_date?->toISOString(),
'status' => $order->status, 'status' => $order->status,
'items' => $formattedItems, 'items' => $formattedItems,
@@ -395,6 +399,7 @@ class PurchaseOrderController extends Controller
'poNumber' => $order->code, 'poNumber' => $order->code,
'supplierId' => (string) $order->vendor_id, 'supplierId' => (string) $order->vendor_id,
'warehouse_id' => (int) $order->warehouse_id, 'warehouse_id' => (int) $order->warehouse_id,
'orderDate' => $order->order_date?->format('Y-m-d'), // 新增
'expectedDate' => $order->expected_delivery_date?->format('Y-m-d'), 'expectedDate' => $order->expected_delivery_date?->format('Y-m-d'),
'status' => $order->status, 'status' => $order->status,
'items' => $formattedItems, 'items' => $formattedItems,
@@ -419,6 +424,7 @@ class PurchaseOrderController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id', 'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|exists:warehouses,id', 'warehouse_id' => 'required|exists:warehouses,id',
'order_date' => 'required|date', // 新增驗證
'expected_delivery_date' => 'nullable|date', 'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string', 'remark' => 'nullable|string',
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled', 'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
@@ -452,6 +458,7 @@ class PurchaseOrderController extends Controller
$order->fill([ $order->fill([
'vendor_id' => $validated['vendor_id'], 'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'], 'warehouse_id' => $validated['warehouse_id'],
'order_date' => $validated['order_date'], // 新增
'expected_delivery_date' => $validated['expected_delivery_date'], 'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount, 'total_amount' => $totalAmount,
'tax_amount' => $taxAmount, 'tax_amount' => $taxAmount,

View File

@@ -17,6 +17,7 @@ class PurchaseOrder extends Model
'vendor_id', 'vendor_id',
'warehouse_id', 'warehouse_id',
'user_id', 'user_id',
'order_date',
'expected_delivery_date', 'expected_delivery_date',
'status', 'status',
'total_amount', 'total_amount',
@@ -26,6 +27,7 @@ class PurchaseOrder extends Model
]; ];
protected $casts = [ protected $casts = [
'order_date' => 'date',
'expected_delivery_date' => 'date', 'expected_delivery_date' => 'date',
'total_amount' => 'decimal:2', 'total_amount' => 'decimal:2',
]; ];

View File

@@ -0,0 +1,30 @@
<?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('warehouses', function (Blueprint $table) {
$table->string('type')->default('standard')->after('description')->comment('倉庫業務類型 (enum)');
$table->string('license_plate')->nullable()->after('type')->comment('車牌號碼 (移動倉用)');
$table->string('driver_name')->nullable()->after('license_plate')->comment('司機姓名 (移動倉用)');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('warehouses', function (Blueprint $table) {
$table->dropColumn(['type', 'license_plate', 'driver_name']);
});
}
};

View File

@@ -0,0 +1,37 @@
<?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('inventories', function (Blueprint $table) {
$table->decimal('unit_cost', 12, 4)->default(0)->after('quantity')->comment('單位成本');
$table->decimal('total_value', 12, 4)->default(0)->after('unit_cost')->comment('庫存總價值');
});
Schema::table('inventory_transactions', function (Blueprint $table) {
$table->decimal('unit_cost', 12, 4)->nullable()->after('quantity')->comment('異動當下的單位成本');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('inventory_transactions', function (Blueprint $table) {
$table->dropColumn('unit_cost');
});
Schema::table('inventories', function (Blueprint $table) {
$table->dropColumn(['unit_cost', 'total_value']);
});
}
};

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('purchase_orders', function (Blueprint $table) {
$table->date('order_date')->nullable()->after('code');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('purchase_orders', function (Blueprint $table) {
$table->dropColumn('order_date');
});
}
};

View File

@@ -34,6 +34,7 @@ class PermissionSeeder extends Seeder
// 庫存管理 // 庫存管理
'inventory.view', 'inventory.view',
'inventory.view_cost', // 查看成本與價值
'inventory.adjust', 'inventory.adjust',
'inventory.transfer', 'inventory.transfer',
@@ -96,7 +97,7 @@ class PermissionSeeder extends Seeder
'products.view', 'products.create', 'products.edit', 'products.delete', 'products.view', 'products.create', 'products.edit', 'products.delete',
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit', 'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
'purchase_orders.delete', 'purchase_orders.publish', 'purchase_orders.delete', 'purchase_orders.publish',
'inventory.view', 'inventory.adjust', 'inventory.transfer', 'inventory.view', 'inventory.view_cost', 'inventory.adjust', 'inventory.transfer',
'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete', 'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete',
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete', 'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete',
'users.view', 'users.create', 'users.edit', 'users.view', 'users.create', 'users.edit',

View File

@@ -41,11 +41,11 @@ export function PurchaseOrderItemsTable({
<TableHeader> <TableHeader>
<TableRow className="bg-gray-50 hover:bg-gray-50"> <TableRow className="bg-gray-50 hover:bg-gray-50">
<TableHead className="w-[20%] text-left"></TableHead> <TableHead className="w-[20%] text-left"></TableHead>
<TableHead className="w-[10%] text-left"></TableHead> <TableHead className="w-[10%] text-left"></TableHead>
<TableHead className="w-[12%] text-left"></TableHead> <TableHead className="w-[12%] text-left"></TableHead>
<TableHead className="w-[12%] text-left"></TableHead> <TableHead className="w-[12%] text-left"></TableHead>
<TableHead className="w-[15%] text-left"></TableHead>
<TableHead className="w-[15%] text-left"> / </TableHead> <TableHead className="w-[15%] text-left"> / </TableHead>
<TableHead className="w-[15%] text-left"></TableHead>
{!isReadOnly && <TableHead className="w-[5%]"></TableHead>} {!isReadOnly && <TableHead className="w-[5%]"></TableHead>}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -146,7 +146,30 @@ export function PurchaseOrderItemsTable({
</div> </div>
</TableCell> </TableCell>
{/* 總金額 (主要輸入欄位) */} {/* 換算採購單價 / 基本單位 (顯示換算結果 - SWAPPED HERE) */}
<TableCell className="text-left">
<div className="flex flex-col">
<div className="text-gray-500 font-medium text-sm">
{formatCurrency(convertedUnitPrice)} / {item.base_unit_name || "個"}
</div>
{convertedUnitPrice > 0 && item.previousPrice && item.previousPrice > 0 && (
<>
{convertedUnitPrice > item.previousPrice && (
<p className="text-[10px] text-amber-600 font-medium animate-pulse">
: {formatCurrency(item.previousPrice)}
</p>
)}
{convertedUnitPrice < item.previousPrice && (
<p className="text-[10px] text-green-600 font-medium">
📉 : {formatCurrency(item.previousPrice)}
</p>
)}
</>
)}
</div>
</TableCell>
{/* 總金額 (主要輸入欄位 - SWAPPED HERE) */}
<TableCell className="text-left"> <TableCell className="text-left">
{isReadOnly ? ( {isReadOnly ? (
<span className="font-bold text-primary">{formatCurrency(item.subtotal)}</span> <span className="font-bold text-primary">{formatCurrency(item.subtotal)}</span>
@@ -178,29 +201,6 @@ export function PurchaseOrderItemsTable({
)} )}
</TableCell> </TableCell>
{/* 換算採購單價 / 基本單位 (顯示換算結果) */}
<TableCell className="text-left">
<div className="flex flex-col">
<div className="text-gray-500 font-medium text-sm">
{formatCurrency(convertedUnitPrice)} / {item.base_unit_name || "個"}
</div>
{convertedUnitPrice > 0 && item.previousPrice && item.previousPrice > 0 && (
<>
{convertedUnitPrice > item.previousPrice && (
<p className="text-[10px] text-amber-600 font-medium animate-pulse">
: {formatCurrency(item.previousPrice)}
</p>
)}
{convertedUnitPrice < item.previousPrice && (
<p className="text-[10px] text-green-600 font-medium">
📉 : {formatCurrency(item.previousPrice)}
</p>
)}
</>
)}
</div>
</TableCell>
{/* 刪除按鈕 */} {/* 刪除按鈕 */}
{!isReadOnly && onRemoveItem && ( {!isReadOnly && onRemoveItem && (
<TableCell className="text-center"> <TableCell className="text-center">

View File

@@ -147,14 +147,21 @@ export default function InventoryTable({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-sm"> <div className="text-sm">
<span className="text-gray-600"> <span className="text-gray-600">
<span className={`font-medium ${isLowStock ? "text-red-600" : "text-gray-900"}`}>{totalQuantity} </span> <span className={`font-medium ${isLowStock ? "text-red-600" : "text-gray-900"}`}>{totalQuantity} {group.baseUnit}</span>
</span> </span>
</div> </div>
<Can permission="inventory.view_cost">
<div className="text-sm">
<span className="text-gray-600">
<span className="font-medium text-gray-900">${group.totalValue?.toLocaleString()}</span>
</span>
</div>
</Can>
{group.safetyStock !== null ? ( {group.safetyStock !== null ? (
<> <>
<div className="text-sm"> <div className="text-sm">
<span className="text-gray-600"> <span className="text-gray-600">
<span className="font-medium text-gray-900">{group.safetyStock} </span> <span className="font-medium text-gray-900">{group.safetyStock} {group.baseUnit}</span>
</span> </span>
</div> </div>
<div> <div>
@@ -193,11 +200,14 @@ export default function InventoryTable({
<TableRow> <TableRow>
<TableHead className="w-[5%]">#</TableHead> <TableHead className="w-[5%]">#</TableHead>
<TableHead className="w-[12%]"></TableHead> <TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[12%]"></TableHead> <TableHead className="w-[10%]"></TableHead>
<TableHead className="w-[15%]"></TableHead> <Can permission="inventory.view_cost">
<TableHead className="w-[14%]"></TableHead> <TableHead className="w-[10%]"></TableHead>
<TableHead className="w-[14%]"></TableHead> <TableHead className="w-[10%]"></TableHead>
<TableHead className="w-[14%]"></TableHead> </Can>
<TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[8%] text-right"></TableHead> <TableHead className="w-[8%] text-right"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -208,9 +218,12 @@ export default function InventoryTable({
<TableCell className="text-grey-2">{index + 1}</TableCell> <TableCell className="text-grey-2">{index + 1}</TableCell>
<TableCell>{batch.batchNumber || "-"}</TableCell> <TableCell>{batch.batchNumber || "-"}</TableCell>
<TableCell> <TableCell>
<span>{batch.quantity}</span> <span>{batch.quantity} {batch.unit}</span>
</TableCell> </TableCell>
<TableCell>{batch.batchNumber || "-"}</TableCell> <Can permission="inventory.view_cost">
<TableCell>${batch.unit_cost?.toLocaleString()}</TableCell>
<TableCell>${batch.total_value?.toLocaleString()}</TableCell>
</Can>
<TableCell> <TableCell>
{batch.expiryDate ? formatDate(batch.expiryDate) : "-"} {batch.expiryDate ? formatDate(batch.expiryDate) : "-"}
</TableCell> </TableCell>

View File

@@ -23,6 +23,7 @@ import { Textarea } from "@/Components/ui/textarea";
import { toast } from "sonner"; import { toast } from "sonner";
import { Warehouse, TransferOrder, TransferOrderStatus } from "@/types/warehouse"; import { Warehouse, TransferOrder, TransferOrderStatus } from "@/types/warehouse";
import { validateTransferOrder, validateTransferQuantity } from "@/utils/validation"; import { validateTransferOrder, validateTransferQuantity } from "@/utils/validation";
import { usePermission } from "@/hooks/usePermission";
export type { TransferOrder }; export type { TransferOrder };
@@ -42,6 +43,8 @@ interface AvailableProduct {
availableQty: number; availableQty: number;
unit: string; unit: string;
expiryDate: string | null; expiryDate: string | null;
unitCost: number; // 新增
totalValue: number; // 新增
} }
export default function TransferOrderDialog({ export default function TransferOrderDialog({
@@ -52,6 +55,9 @@ export default function TransferOrderDialog({
// inventories, // inventories,
onSave, onSave,
}: TransferOrderDialogProps) { }: TransferOrderDialogProps) {
const { can } = usePermission();
const canViewCost = can('inventory.view_cost');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
sourceWarehouseId: "", sourceWarehouseId: "",
targetWarehouseId: "", targetWarehouseId: "",
@@ -106,7 +112,9 @@ export default function TransferOrderDialog({
batchNumber: item.batch_number, batchNumber: item.batch_number,
availableQty: item.quantity, availableQty: item.quantity,
unit: item.unit_name, unit: item.unit_name,
expiryDate: item.expiry_date expiryDate: item.expiry_date,
unitCost: item.unit_cost, // 映射
totalValue: item.total_value, // 映射
})); }));
setAvailableProducts(mappedData); setAvailableProducts(mappedData);
}) })
@@ -249,7 +257,7 @@ export default function TransferOrderDialog({
onValueChange={handleProductChange} onValueChange={handleProductChange}
disabled={!formData.sourceWarehouseId || !!order} disabled={!formData.sourceWarehouseId || !!order}
options={availableProducts.map((product) => ({ options={availableProducts.map((product) => ({
label: `${product.productName} | 批號: ${product.batchNumber || '-'} | 效期: ${product.expiryDate || '-'} (庫存: ${product.availableQty} ${product.unit})`, label: `${product.productName} | 批號: ${product.batchNumber || '-'} | 效期: ${product.expiryDate || '-'} (庫存: ${product.availableQty} ${product.unit})${canViewCost ? ` | 成本: $${product.unitCost?.toLocaleString()}` : ''}`,
value: `${product.productId}|||${product.batchNumber}`, value: `${product.productId}|||${product.batchNumber}`,
}))} }))}
placeholder="選擇商品與批號" placeholder="選擇商品與批號"

View File

@@ -32,6 +32,15 @@ interface WarehouseCardProps {
onEdit: (warehouse: Warehouse) => void; onEdit: (warehouse: Warehouse) => void;
} }
const WAREHOUSE_TYPE_LABELS: Record<string, string> = {
standard: "標準倉",
production: "生產倉",
retail: "門市倉",
vending: "販賣機",
transit: "在途倉",
quarantine: "瑕疵倉",
};
export default function WarehouseCard({ export default function WarehouseCard({
warehouse, warehouse,
stats, stats,
@@ -71,6 +80,16 @@ export default function WarehouseCard({
<Info className="h-5 w-5" /> <Info className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="flex gap-2 mt-1">
<Badge variant="outline" className="text-xs font-normal">
{WAREHOUSE_TYPE_LABELS[warehouse.type || 'standard'] || '標準倉'}
</Badge>
{warehouse.type === 'transit' && warehouse.license_plate && (
<Badge variant="secondary" className="text-xs font-normal bg-yellow-100 text-yellow-800 border-yellow-200">
{warehouse.license_plate}
</Badge>
)}
</div>
</div> </div>
</div> </div>
@@ -107,6 +126,14 @@ export default function WarehouseCard({
)} )}
</div> </div>
</div> </div>
{/* 移動倉司機資訊 */}
{warehouse.type === 'transit' && warehouse.driver_name && (
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<span className="text-sm text-gray-500"></span>
<span className="text-sm font-medium text-gray-900">{warehouse.driver_name}</span>
</div>
)}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
/** /**
* 倉庫對話框元件 * 倉庫對話框元件
* 重構後:加入驗證邏輯 * 重構後:加入驗證邏輯與業務類型支援
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -27,9 +27,10 @@ import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea"; import { Textarea } from "@/Components/ui/textarea";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { Warehouse } from "@/types/warehouse"; import { Warehouse, WarehouseType } from "@/types/warehouse";
import { validateWarehouse } from "@/utils/validation"; import { validateWarehouse } from "@/utils/validation";
import { toast } from "sonner"; import { toast } from "sonner";
import { SearchableSelect } from "@/Components/ui/searchable-select";
interface WarehouseDialogProps { interface WarehouseDialogProps {
open: boolean; open: boolean;
@@ -39,6 +40,15 @@ interface WarehouseDialogProps {
onDelete?: (warehouseId: string) => void; onDelete?: (warehouseId: string) => void;
} }
const WAREHOUSE_TYPE_OPTIONS: { label: string; value: WarehouseType }[] = [
{ label: "標準倉 (總倉)", value: "standard" },
{ label: "生產倉 (廚房/加工)", value: "production" },
{ label: "門市倉 (前台销售)", value: "retail" },
{ label: "販賣機 (IoT設備)", value: "vending" },
{ label: "在途倉 (物流車)", value: "transit" },
{ label: "瑕疵倉 (報廢/檢驗)", value: "quarantine" },
];
export default function WarehouseDialog({ export default function WarehouseDialog({
open, open,
onOpenChange, onOpenChange,
@@ -51,13 +61,19 @@ export default function WarehouseDialog({
name: string; name: string;
address: string; address: string;
description: string; description: string;
type: WarehouseType;
is_sellable: boolean; is_sellable: boolean;
license_plate: string;
driver_name: string;
}>({ }>({
code: "", code: "",
name: "", name: "",
address: "", address: "",
description: "", description: "",
type: "standard",
is_sellable: true, is_sellable: true,
license_plate: "",
driver_name: "",
}); });
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -69,7 +85,10 @@ export default function WarehouseDialog({
name: warehouse.name, name: warehouse.name,
address: warehouse.address || "", address: warehouse.address || "",
description: warehouse.description || "", description: warehouse.description || "",
type: warehouse.type || "standard",
is_sellable: warehouse.is_sellable ?? true, is_sellable: warehouse.is_sellable ?? true,
license_plate: warehouse.license_plate || "",
driver_name: warehouse.driver_name || "",
}); });
} else { } else {
setFormData({ setFormData({
@@ -77,7 +96,10 @@ export default function WarehouseDialog({
name: "", name: "",
address: "", address: "",
description: "", description: "",
type: "standard",
is_sellable: true, is_sellable: true,
license_plate: "",
driver_name: "",
}); });
} }
}, [warehouse, open]); }, [warehouse, open]);
@@ -136,8 +158,21 @@ export default function WarehouseDialog({
/> />
</div> </div>
{/* 倉庫名稱 */} {/* 倉庫類型 */}
<div className="space-y-2"> <div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<SearchableSelect
value={formData.type}
onValueChange={(val) => setFormData({ ...formData, type: val as WarehouseType })}
options={WAREHOUSE_TYPE_OPTIONS}
placeholder="選擇倉庫類型"
className="h-9"
showSearch={false}
/>
</div>
{/* 倉庫名稱 */}
<div className="space-y-2 col-span-2">
<Label htmlFor="name"> <Label htmlFor="name">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</Label> </Label>
@@ -147,11 +182,43 @@ export default function WarehouseDialog({
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例:中央倉庫" placeholder="例:中央倉庫"
required required
className="h-9"
/> />
</div> </div>
</div> </div>
</div> </div>
{/* 移動倉專屬資訊 */}
{formData.type === 'transit' && (
<div className="space-y-4 bg-yellow-50 p-4 rounded-lg border border-yellow-100">
<div className="border-b border-yellow-200 pb-2">
<h4 className="text-sm text-yellow-800 font-medium"> ()</h4>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="license_plate"></Label>
<Input
id="license_plate"
value={formData.license_plate}
onChange={(e) => setFormData({ ...formData, license_plate: e.target.value })}
placeholder="例ABC-1234"
className="h-9 bg-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="driver_name"></Label>
<Input
id="driver_name"
value={formData.driver_name}
onChange={(e) => setFormData({ ...formData, driver_name: e.target.value })}
placeholder="例:王小明"
className="h-9 bg-white"
/>
</div>
</div>
</div>
)}
{/* 銷售設定 */} {/* 銷售設定 */}
<div className="space-y-4"> <div className="space-y-4">
<div className="border-b pb-2"> <div className="border-b pb-2">
@@ -167,6 +234,9 @@ export default function WarehouseDialog({
/> />
<Label htmlFor="is_sellable"></Label> <Label htmlFor="is_sellable"></Label>
</div> </div>
<p className="text-xs text-gray-500 ml-6">
POS
</p>
</div> </div>
{/* 區塊 B位置 */} {/* 區塊 B位置 */}
@@ -186,6 +256,7 @@ export default function WarehouseDialog({
onChange={(e) => setFormData({ ...formData, address: e.target.value })} onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="例台北市信義區信義路五段7號" placeholder="例台北市信義區信義路五段7號"
required required
className="h-9"
/> />
</div> </div>

View File

@@ -39,6 +39,7 @@ export default function CreatePurchaseOrder({
const { const {
supplierId, supplierId,
expectedDate, expectedDate,
orderDate,
items, items,
notes, notes,
selectedSupplier, selectedSupplier,
@@ -46,6 +47,7 @@ export default function CreatePurchaseOrder({
warehouseId, warehouseId,
setSupplierId, setSupplierId,
setExpectedDate, setExpectedDate,
setOrderDate,
setNotes, setNotes,
setWarehouseId, setWarehouseId,
addItem, addItem,
@@ -87,6 +89,11 @@ export default function CreatePurchaseOrder({
return; return;
} }
if (!orderDate) {
toast.error("請選擇採購日期");
return;
}
if (!expectedDate) { if (!expectedDate) {
toast.error("請選擇預計到貨日期"); toast.error("請選擇預計到貨日期");
return; return;
@@ -120,6 +127,7 @@ export default function CreatePurchaseOrder({
const data = { const data = {
vendor_id: supplierId, vendor_id: supplierId,
warehouse_id: warehouseId, warehouse_id: warehouseId,
order_date: orderDate,
expected_delivery_date: expectedDate, expected_delivery_date: expectedDate,
remark: notes, remark: notes,
status: status, status: status,
@@ -235,6 +243,18 @@ export default function CreatePurchaseOrder({
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700">
<span className="text-red-500">*</span>
</label>
<Input
type="date"
value={orderDate || ""}
onChange={(e) => setOrderDate(e.target.value)}
className="block w-full"
/>
</div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-sm font-bold text-gray-700"> <label className="text-sm font-bold text-gray-700">

View File

@@ -88,6 +88,10 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
<span className="text-sm text-gray-500 block mb-1"></span> <span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{formatDateTime(order.createdAt)}</span> <span className="font-medium text-gray-900">{formatDateTime(order.createdAt)}</span>
</div> </div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{order.orderDate || "-"}</span>
</div>
<div> <div>
<span className="text-sm text-gray-500 block mb-1"></span> <span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{order.expectedDate || "-"}</span> <span className="font-medium text-gray-900">{order.expectedDate || "-"}</span>

View File

@@ -4,7 +4,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { PurchaseOrder, PurchaseOrderItem, Supplier, PurchaseOrderStatus } from "@/types/purchase-order"; import type { PurchaseOrder, PurchaseOrderItem, Supplier, PurchaseOrderStatus } from "@/types/purchase-order";
import { calculateSubtotal } from "@/utils/purchase-order"; import { calculateSubtotal, getTodayDate } from "@/utils/purchase-order";
interface UsePurchaseOrderFormProps { interface UsePurchaseOrderFormProps {
order?: PurchaseOrder; order?: PurchaseOrder;
@@ -14,6 +14,7 @@ interface UsePurchaseOrderFormProps {
export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormProps) { export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormProps) {
const [supplierId, setSupplierId] = useState(order?.supplierId || ""); const [supplierId, setSupplierId] = useState(order?.supplierId || "");
const [expectedDate, setExpectedDate] = useState(order?.expectedDate || ""); const [expectedDate, setExpectedDate] = useState(order?.expectedDate || "");
const [orderDate, setOrderDate] = useState(order?.orderDate || getTodayDate());
const [items, setItems] = useState<PurchaseOrderItem[]>(order?.items || []); const [items, setItems] = useState<PurchaseOrderItem[]>(order?.items || []);
const [notes, setNotes] = useState(order?.remark || ""); const [notes, setNotes] = useState(order?.remark || "");
const [status, setStatus] = useState<PurchaseOrderStatus>(order?.status || "draft"); const [status, setStatus] = useState<PurchaseOrderStatus>(order?.status || "draft");
@@ -32,6 +33,7 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
if (order) { if (order) {
setSupplierId(order.supplierId); setSupplierId(order.supplierId);
setExpectedDate(order.expectedDate); setExpectedDate(order.expectedDate);
setOrderDate(order.orderDate || getTodayDate());
setItems(order.items || []); setItems(order.items || []);
setNotes(order.remark || ""); setNotes(order.remark || "");
setStatus(order.status); setStatus(order.status);
@@ -52,6 +54,7 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
const resetForm = () => { const resetForm = () => {
setSupplierId(""); setSupplierId("");
setExpectedDate(""); setExpectedDate("");
setOrderDate(getTodayDate());
setItems([]); setItems([]);
setNotes(""); setNotes("");
setStatus("draft"); setStatus("draft");
@@ -159,6 +162,7 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
// State // State
supplierId, supplierId,
expectedDate, expectedDate,
orderDate,
items, items,
notes, notes,
status, status,
@@ -174,6 +178,7 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
// Setters // Setters
setSupplierId, setSupplierId,
setExpectedDate, setExpectedDate,
setOrderDate,
setNotes, setNotes,
setStatus, setStatus,
setWarehouseId, setWarehouseId,

View File

@@ -67,6 +67,7 @@ export interface PurchaseOrder {
poNumber: string; poNumber: string;
supplierId: string; supplierId: string;
supplierName: string; supplierName: string;
orderDate?: string; // 採購日期
expectedDate: string; expectedDate: string;
status: PurchaseOrderStatus; status: PurchaseOrderStatus;
items: PurchaseOrderItem[]; items: PurchaseOrderItem[];

View File

@@ -2,7 +2,7 @@
* 倉庫相關型別定義 * 倉庫相關型別定義
*/ */
export type WarehouseType = "中央倉庫" | "門市"; export type WarehouseType = "standard" | "production" | "retail" | "vending" | "transit" | "quarantine";
/** /**
* 門市資訊 * 門市資訊
@@ -19,17 +19,17 @@ export interface Warehouse {
name: string; name: string;
address?: string; address?: string;
description?: string; description?: string;
createdAt?: string; // 對應 created_at 但前端可能習慣 camelCase後端傳回 snake_caseInertia 會保持原樣。 createdAt?: string;
// 若後端 Resource 沒轉 camelCase這裡應該用 snake_case 或在前端轉
// 為求簡單,我修改 interface 為 snake_case 以匹配 Laravel 預設 Response
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
total_quantity?: number; total_quantity?: number;
low_stock_count?: number; low_stock_count?: number;
type?: WarehouseType; type?: WarehouseType;
is_sellable?: boolean; // 新增欄位 is_sellable?: boolean;
book_stock?: number; // 帳面庫存 license_plate?: string; // 車牌號碼 (移動倉)
available_stock?: number; // 可用庫存 driver_name?: string; // 司機姓名 (移動倉)
book_stock?: number;
available_stock?: number;
} }
// 倉庫中的庫存項目 // 倉庫中的庫存項目
export interface WarehouseInventory { export interface WarehouseInventory {
@@ -41,6 +41,8 @@ export interface WarehouseInventory {
productCode: string; productCode: string;
unit: string; unit: string;
quantity: number; quantity: number;
unit_cost?: number; // 單位成本
total_value?: number; // 總價值
safetyStock: number | null; safetyStock: number | null;
status?: '正常' | '低於'; // 後端可能回傳的狀態 status?: '正常' | '低於'; // 後端可能回傳的狀態
batchNumber: string; // 批號 (Mock for now) batchNumber: string; // 批號 (Mock for now)
@@ -56,6 +58,7 @@ export interface GroupedInventory {
productCode: string; productCode: string;
baseUnit: string; baseUnit: string;
totalQuantity: number; totalQuantity: number;
totalValue?: number; // 總價值總計
safetyStock: number | null; // 以商品層級顯示的安全庫存 safetyStock: number | null; // 以商品層級顯示的安全庫存
status: '正常' | '低於'; status: '正常' | '低於';
batches: WarehouseInventory[]; // 該商品下的所有批號庫存 batches: WarehouseInventory[]; // 該商品下的所有批號庫存
@@ -89,6 +92,7 @@ export interface Product {
export interface WarehouseStats { export interface WarehouseStats {
totalQuantity: number; totalQuantity: number;
totalValue?: number; // 倉庫總值
lowStockCount: number; lowStockCount: number;
replenishmentNeeded: number; replenishmentNeeded: number;
} }
@@ -145,6 +149,7 @@ export interface InventoryTransaction {
productName: string; productName: string;
batchNumber: string; batchNumber: string;
quantity: number; // 正數為入庫,負數為出庫 quantity: number; // 正數為入庫,負數為出庫
unit_cost?: number; // 異動時的成本
transactionType: TransactionType; transactionType: TransactionType;
reason?: string; reason?: string;
notes?: string; notes?: string;
@@ -161,6 +166,7 @@ export interface InboundItem {
productId: string; productId: string;
productName: string; productName: string;
quantity: number; quantity: number;
unit_cost?: number; // 入庫單價
unit: string; unit: string;
baseUnit?: string; baseUnit?: string;
largeUnit?: string; largeUnit?: string;