feat: 修正庫存與撥補單邏輯並整合文件
1. 修復倉庫統計數據加總與樣式。 2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。 3. 撥補單商品列表加入批號與效期顯示。 4. 修正撥補單儲存邏輯以支援精確批號轉移。 5. 整合 FEATURES.md 至 README.md。
This commit is contained in:
@@ -5,11 +5,16 @@ namespace App\Modules\Inventory\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
|
||||
|
||||
class InventoryController extends Controller
|
||||
{
|
||||
public function index(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
|
||||
public function index(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
$warehouse->load([
|
||||
'inventories.product.category',
|
||||
@@ -17,7 +22,7 @@ class InventoryController extends Controller
|
||||
'inventories.lastIncomingTransaction',
|
||||
'inventories.lastOutgoingTransaction'
|
||||
]);
|
||||
$allProducts = \App\Modules\Inventory\Models\Product::with('category')->get();
|
||||
$allProducts = Product::with('category')->get();
|
||||
|
||||
// 1. 準備 availableProducts
|
||||
$availableProducts = $allProducts->map(function ($product) {
|
||||
@@ -98,7 +103,7 @@ class InventoryController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/Inventory', [
|
||||
return Inertia::render('Warehouse/Inventory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventories' => $inventories,
|
||||
'safetyStockSettings' => $safetyStockSettings,
|
||||
@@ -106,10 +111,10 @@ class InventoryController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(\App\Modules\Inventory\Models\Warehouse $warehouse)
|
||||
public function create(Warehouse $warehouse)
|
||||
{
|
||||
// 取得所有商品供前端選單使用
|
||||
$products = \App\Modules\Inventory\Models\Product::with(['baseUnit', 'largeUnit'])
|
||||
$products = Product::with(['baseUnit', 'largeUnit'])
|
||||
->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate')
|
||||
->get()
|
||||
->map(function ($product) {
|
||||
@@ -123,13 +128,13 @@ class InventoryController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/AddInventory', [
|
||||
return Inertia::render('Warehouse/AddInventory', [
|
||||
'warehouse' => $warehouse,
|
||||
'products' => $products,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
|
||||
public function store(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'inboundDate' => 'required|date',
|
||||
@@ -144,22 +149,22 @@ class InventoryController extends Controller
|
||||
'items.*.expiryDate' => 'nullable|date',
|
||||
]);
|
||||
|
||||
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $warehouse) {
|
||||
return DB::transaction(function () use ($validated, $warehouse) {
|
||||
foreach ($validated['items'] as $item) {
|
||||
$inventory = null;
|
||||
|
||||
if ($item['batchMode'] === 'existing') {
|
||||
// 模式 A:選擇現有批號 (包含已刪除的也要能找回來累加)
|
||||
$inventory = \App\Modules\Inventory\Models\Inventory::withTrashed()->findOrFail($item['inventoryId']);
|
||||
$inventory = Inventory::withTrashed()->findOrFail($item['inventoryId']);
|
||||
if ($inventory->trashed()) {
|
||||
$inventory->restore();
|
||||
}
|
||||
} else {
|
||||
// 模式 B:建立新批號
|
||||
$originCountry = $item['originCountry'] ?? 'TW';
|
||||
$product = \App\Modules\Inventory\Models\Product::find($item['productId']);
|
||||
$product = Product::find($item['productId']);
|
||||
|
||||
$batchNumber = \App\Modules\Inventory\Models\Inventory::generateBatchNumber(
|
||||
$batchNumber = Inventory::generateBatchNumber(
|
||||
$product->code ?? 'UNK',
|
||||
$originCountry,
|
||||
$validated['inboundDate']
|
||||
@@ -210,12 +215,12 @@ class InventoryController extends Controller
|
||||
/**
|
||||
* API: 取得商品在特定倉庫的所有批號,並回傳當前日期/產地下的一個流水號
|
||||
*/
|
||||
public function getBatches(\App\Modules\Inventory\Models\Warehouse $warehouse, $productId, \Illuminate\Http\Request $request)
|
||||
public function getBatches(Warehouse $warehouse, $productId, Request $request)
|
||||
{
|
||||
$originCountry = $request->query('originCountry', 'TW');
|
||||
$arrivalDate = $request->query('arrivalDate', now()->format('Y-m-d'));
|
||||
|
||||
$batches = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $warehouse->id)
|
||||
$batches = Inventory::where('warehouse_id', $warehouse->id)
|
||||
->where('product_id', $productId)
|
||||
->get()
|
||||
->map(function ($inventory) {
|
||||
@@ -229,10 +234,10 @@ class InventoryController extends Controller
|
||||
});
|
||||
|
||||
// 計算下一個流水號
|
||||
$product = \App\Modules\Inventory\Models\Product::find($productId);
|
||||
$product = Product::find($productId);
|
||||
$nextSequence = '01';
|
||||
if ($product) {
|
||||
$batchNumber = \App\Modules\Inventory\Models\Inventory::generateBatchNumber(
|
||||
$batchNumber = Inventory::generateBatchNumber(
|
||||
$product->code ?? 'UNK',
|
||||
$originCountry,
|
||||
$arrivalDate
|
||||
@@ -246,7 +251,7 @@ class InventoryController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
|
||||
public function edit(Request $request, Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
|
||||
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
|
||||
@@ -254,7 +259,7 @@ class InventoryController extends Controller
|
||||
return redirect()->back()->with('error', '無法編輯範例資料');
|
||||
}
|
||||
|
||||
$inventory = \App\Modules\Inventory\Models\Inventory::with(['product', 'transactions' => function($query) {
|
||||
$inventory = Inventory::with(['product', 'transactions' => function($query) {
|
||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||
}, 'transactions.user'])->findOrFail($inventoryId);
|
||||
|
||||
@@ -284,20 +289,20 @@ class InventoryController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/EditInventory', [
|
||||
return Inertia::render('Warehouse/EditInventory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => $inventoryData,
|
||||
'transactions' => $transactions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
|
||||
public function update(Request $request, Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
// 若是 product ID (舊邏輯),先轉為 inventory
|
||||
// 但新路由我們傳的是 inventory ID
|
||||
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
|
||||
|
||||
$inventory = \App\Modules\Inventory\Models\Inventory::find($inventoryId);
|
||||
$inventory = Inventory::find($inventoryId);
|
||||
|
||||
// 如果找不到 (可能是舊路由傳 product ID)
|
||||
if (!$inventory) {
|
||||
@@ -322,7 +327,7 @@ class InventoryController extends Controller
|
||||
'lastOutboundDate' => 'nullable|date',
|
||||
]);
|
||||
|
||||
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $inventory) {
|
||||
return DB::transaction(function () use ($validated, $inventory) {
|
||||
$currentQty = (float) $inventory->quantity;
|
||||
$newQty = (float) $validated['quantity'];
|
||||
|
||||
@@ -395,9 +400,9 @@ class InventoryController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(\App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
|
||||
public function destroy(Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
$inventory = \App\Modules\Inventory\Models\Inventory::findOrFail($inventoryId);
|
||||
$inventory = Inventory::findOrFail($inventoryId);
|
||||
|
||||
// 庫存 > 0 不允許刪除 (哪怕是軟刪除)
|
||||
if ($inventory->quantity > 0) {
|
||||
@@ -430,7 +435,7 @@ class InventoryController extends Controller
|
||||
|
||||
if ($productId) {
|
||||
// 商品層級查詢
|
||||
$inventories = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $warehouse->id)
|
||||
$inventories = Inventory::where('warehouse_id', $warehouse->id)
|
||||
->where('product_id', $productId)
|
||||
->with(['product', 'transactions' => function($query) {
|
||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||
@@ -491,7 +496,7 @@ class InventoryController extends Controller
|
||||
];
|
||||
})->values();
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/InventoryHistory', [
|
||||
return Inertia::render('Warehouse/InventoryHistory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => [
|
||||
'id' => 'product-' . $productId,
|
||||
@@ -505,7 +510,7 @@ class InventoryController extends Controller
|
||||
|
||||
if ($inventoryId) {
|
||||
// 單一批號查詢
|
||||
$inventory = \App\Modules\Inventory\Models\Inventory::with(['product', 'transactions' => function($query) {
|
||||
$inventory = Inventory::with(['product', 'transactions' => function($query) {
|
||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||
}, 'transactions.user'])->findOrFail($inventoryId);
|
||||
|
||||
@@ -521,7 +526,7 @@ class InventoryController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/InventoryHistory', [
|
||||
return Inertia::render('Warehouse/InventoryHistory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => [
|
||||
'id' => (string) $inventory->id,
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Unit;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -13,7 +14,7 @@ use Inertia\Response;
|
||||
class ProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
* 顯示資源列表。
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
@@ -40,7 +41,7 @@ class ProductController extends Controller
|
||||
$sortField = $request->input('sort_field', 'id');
|
||||
$sortDirection = $request->input('sort_direction', 'desc');
|
||||
|
||||
// Define allowed sort fields to prevent SQL injection
|
||||
// 定義允許的排序欄位以防止 SQL 注入
|
||||
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit_id', 'conversion_rate'];
|
||||
if (!in_array($sortField, $allowedSorts)) {
|
||||
$sortField = 'id';
|
||||
@@ -49,11 +50,11 @@ class ProductController extends Controller
|
||||
$sortDirection = 'desc';
|
||||
}
|
||||
|
||||
// Handle relation sorting (category name) separately if needed, or simple join
|
||||
// 如果需要,分別處理關聯排序(分類名稱),或簡單的 join
|
||||
if ($sortField === 'category_id') {
|
||||
// Join categories for sorting by name? Or just by ID?
|
||||
// Simple approach: sort by ID for now, or join if user wants name sort.
|
||||
// Let's assume standard field sorting first.
|
||||
// 加入分類以便按名稱排序?還是僅按 ID?
|
||||
// 簡單方法:目前按 ID 排序,如果使用者想要按名稱排序則 join。
|
||||
// 先假設標準欄位排序。
|
||||
$query->orderBy('category_id', $sortDirection);
|
||||
} else {
|
||||
$query->orderBy($sortField, $sortDirection);
|
||||
@@ -61,18 +62,49 @@ class ProductController extends Controller
|
||||
|
||||
$products = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
$categories = \App\Modules\Inventory\Models\Category::where('is_active', true)->get();
|
||||
$products->getCollection()->transform(function ($product) {
|
||||
return (object) [
|
||||
'id' => (string) $product->id,
|
||||
'code' => $product->code,
|
||||
'name' => $product->name,
|
||||
'categoryId' => $product->category_id,
|
||||
'category' => $product->category ? (object) [
|
||||
'id' => $product->category->id,
|
||||
'name' => $product->category->name,
|
||||
] : null,
|
||||
'brand' => $product->brand,
|
||||
'specification' => $product->specification,
|
||||
'baseUnitId' => $product->base_unit_id,
|
||||
'baseUnit' => $product->baseUnit ? (object) [
|
||||
'id' => $product->baseUnit->id,
|
||||
'name' => $product->baseUnit->name,
|
||||
] : null,
|
||||
'largeUnitId' => $product->large_unit_id,
|
||||
'largeUnit' => $product->largeUnit ? (object) [
|
||||
'id' => $product->largeUnit->id,
|
||||
'name' => $product->largeUnit->name,
|
||||
] : null,
|
||||
'purchaseUnitId' => $product->purchase_unit_id,
|
||||
'purchaseUnit' => $product->purchaseUnit ? (object) [
|
||||
'id' => $product->purchaseUnit->id,
|
||||
'name' => $product->purchaseUnit->name,
|
||||
] : null,
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
];
|
||||
});
|
||||
|
||||
$categories = Category::where('is_active', true)->get();
|
||||
|
||||
return Inertia::render('Product/Index', [
|
||||
'products' => $products,
|
||||
'categories' => $categories,
|
||||
'units' => Unit::all(),
|
||||
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
* 將新建立的資源儲存到儲存體中。
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
@@ -107,7 +139,7 @@ class ProductController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
* 更新儲存體中的指定資源。
|
||||
*/
|
||||
public function update(Request $request, Product $product)
|
||||
{
|
||||
@@ -141,7 +173,7 @@ class ProductController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
* 從儲存體中移除指定資源。
|
||||
*/
|
||||
public function destroy(Product $product)
|
||||
{
|
||||
|
||||
@@ -29,25 +29,30 @@ class TransferOrderController extends Controller
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($validated) {
|
||||
// 1. 檢查來源倉庫庫存
|
||||
// 1. 檢查來源倉庫庫存 (精確匹配產品與批號)
|
||||
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
|
||||
->where('product_id', $validated['productId'])
|
||||
->where('batch_number', $validated['batchNumber'])
|
||||
->first();
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => ['來源倉庫庫存不足'],
|
||||
'quantity' => ['來源倉庫指定批號庫存不足'],
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. 獲取或建立目標倉庫庫存
|
||||
// 2. 獲取或建立目標倉庫庫存 (精確匹配產品與批號,並繼承效期與品質狀態)
|
||||
$targetInventory = Inventory::firstOrCreate(
|
||||
[
|
||||
'warehouse_id' => $validated['targetWarehouseId'],
|
||||
'product_id' => $validated['productId'],
|
||||
'batch_number' => $validated['batchNumber'],
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'expiry_date' => $sourceInventory->expiry_date,
|
||||
'quality_status' => $sourceInventory->quality_status,
|
||||
'origin_country' => $sourceInventory->origin_country,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -109,11 +114,12 @@ class TransferOrderController extends Controller
|
||||
->get()
|
||||
->map(function ($inv) {
|
||||
return [
|
||||
'productId' => (string) $inv->product_id,
|
||||
'productName' => $inv->product->name,
|
||||
'batchNumber' => 'BATCH-' . $inv->id, // 模擬批號
|
||||
'availableQty' => (float) $inv->quantity,
|
||||
'unit' => $inv->product->baseUnit?->name ?? '個',
|
||||
'product_id' => (string) $inv->product_id,
|
||||
'product_name' => $inv->product->name,
|
||||
'batch_number' => $inv->batch_number,
|
||||
'quantity' => (float) $inv->quantity,
|
||||
'unit_name' => $inv->product->baseUnit?->name ?? '個',
|
||||
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Http\Request;
|
||||
class UnitController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
* 將新建立的資源儲存到儲存體中。
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
@@ -31,7 +31,7 @@ class UnitController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
* 更新儲存體中的指定資源。
|
||||
*/
|
||||
public function update(Request $request, Unit $unit)
|
||||
{
|
||||
@@ -51,11 +51,11 @@ class UnitController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
* 從儲存體中移除指定資源。
|
||||
*/
|
||||
public function destroy(Unit $unit)
|
||||
{
|
||||
// Check if unit is used in any product
|
||||
// 檢查單位是否已被任何商品使用
|
||||
$isUsed = Product::where('base_unit_id', $unit->id)
|
||||
->orWhere('large_unit_id', $unit->id)
|
||||
->orWhere('purchase_unit_id', $unit->id)
|
||||
|
||||
@@ -24,13 +24,45 @@ class WarehouseController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
$warehouses = $query->withSum('inventories as total_quantity', 'quantity')
|
||||
$warehouses = $query->withSum('inventories as book_stock', 'quantity') // 帳面庫存 = 所有庫存總和
|
||||
->withSum(['inventories as available_stock' => function ($query) {
|
||||
// 可用庫存 = 庫存 > 0 且 品質正常 且 (未過期 或 無效期)
|
||||
$query->where('quantity', '>', 0)
|
||||
->where('quality_status', 'normal')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expiry_date')
|
||||
->orWhere('expiry_date', '>=', now());
|
||||
});
|
||||
}], 'quantity')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
// 修正各倉庫列表中的可用庫存計算:若倉庫不可銷售,則可用庫存為 0
|
||||
$warehouses->getCollection()->transform(function ($w) {
|
||||
if (!$w->is_sellable) {
|
||||
$w->available_stock = 0;
|
||||
}
|
||||
return $w;
|
||||
});
|
||||
|
||||
// 計算全域總計 (不分頁)
|
||||
$totals = [
|
||||
'available_stock' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0)
|
||||
->where('quality_status', 'normal')
|
||||
->whereHas('warehouse', function ($q) {
|
||||
$q->where('is_sellable', true);
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expiry_date')
|
||||
->orWhere('expiry_date', '>=', now());
|
||||
})->sum('quantity'),
|
||||
'book_stock' => \App\Modules\Inventory\Models\Inventory::sum('quantity'),
|
||||
];
|
||||
|
||||
return Inertia::render('Warehouse/Index', [
|
||||
'warehouses' => $warehouses,
|
||||
'totals' => $totals,
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
}
|
||||
@@ -41,9 +73,10 @@ class WarehouseController extends Controller
|
||||
'name' => 'required|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_sellable' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// Auto-generate code
|
||||
// 自動產生代碼
|
||||
$prefix = 'WH';
|
||||
$lastWarehouse = Warehouse::latest('id')->first();
|
||||
$nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1;
|
||||
@@ -62,6 +95,7 @@ class WarehouseController extends Controller
|
||||
'name' => 'required|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_sellable' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$warehouse->update($validated);
|
||||
|
||||
Reference in New Issue
Block a user