feat: 修正庫存與撥補單邏輯並整合文件
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

1. 修復倉庫統計數據加總與樣式。
2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。
3. 撥補單商品列表加入批號與效期顯示。
4. 修正撥補單儲存邏輯以支援精確批號轉移。
5. 整合 FEATURES.md 至 README.md。
This commit is contained in:
2026-01-26 14:59:24 +08:00
parent b0848a6bb8
commit 106de4e945
81 changed files with 4118 additions and 1023 deletions

View File

@@ -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,

View File

@@ -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)
{

View File

@@ -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,
];
});

View File

@@ -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)

View File

@@ -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);