feat(inventory): 實作過期與瑕疵庫存總計顯示,並強化庫存明細過期提示

This commit is contained in:
2026-02-05 15:50:14 +08:00
parent ba3c10ac13
commit a518d390bd
16 changed files with 751 additions and 574 deletions

View File

@@ -113,57 +113,77 @@ class ProductController extends Controller
]);
}
/**
* 顯示建立表單。
*/
public function create(): Response
{
return Inertia::render('Product/Create', [
'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]),
]);
}
/**
* 將新建立的資源儲存到儲存體中。
*/
public function store(Request $request)
{
$validated = $request->validate([
'code' => 'required|string|min:2|max:8|unique:products,code',
'barcode' => 'required|string|unique:products,barcode',
'code' => 'nullable|unique:products,code',
'barcode' => 'nullable|unique:products,barcode',
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string',
'base_unit_id' => 'required|exists:units,id',
'large_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
'purchase_unit_id' => 'nullable|exists:units,id',
'purchase_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'nullable|numeric|min:0',
'location' => 'nullable|string|max:255',
'cost_price' => 'nullable|numeric|min:0',
'price' => 'nullable|numeric|min:0',
'member_price' => 'nullable|numeric|min:0',
'wholesale_price' => 'nullable|numeric|min:0',
], [
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 8 碼',
'code.min' => '商品代號最少 2 碼',
'code.unique' => '商品代號已存在',
'barcode.required' => '條碼編號為必填',
'barcode.unique' => '條碼編號已存在',
'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在',
'base_unit_id.required' => '基本庫存單位為必填',
'base_unit_id.exists' => '所選基本單位不存在',
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
'conversion_rate.numeric' => '換算率必須為數字',
'conversion_rate.min' => '換算率最小為 0.0001',
'cost_price.numeric' => '成本價必須為數字',
'cost_price.min' => '成本價不能小於 0',
'price.numeric' => '售價必須為數字',
'price.min' => '售價不能小於 0',
'member_price.numeric' => '會員價必須為數字',
'member_price.min' => '會員價不能小於 0',
'wholesale_price.numeric' => '批發價必須為數字',
'wholesale_price.min' => '批發價不能小於 0',
'is_active' => 'boolean',
]);
if (empty($validated['code'])) {
$validated['code'] = $this->generateRandomCode();
}
$product = Product::create($validated);
return redirect()->back()->with('success', '商品已建立');
return redirect()->route('products.index')->with('success', '商品已建立');
}
/**
* 顯示編輯表單。
*/
public function edit(Product $product): Response
{
return Inertia::render('Product/Edit', [
'product' => (object) [
'id' => (string) $product->id,
'code' => $product->code,
'barcode' => $product->barcode,
'name' => $product->name,
'categoryId' => $product->category_id,
'brand' => $product->brand,
'specification' => $product->specification,
'baseUnitId' => $product->base_unit_id,
'largeUnitId' => $product->large_unit_id,
'conversionRate' => (float) $product->conversion_rate,
'purchaseUnitId' => $product->purchase_unit_id,
'location' => $product->location,
'cost_price' => (float) $product->cost_price,
'price' => (float) $product->price,
'member_price' => (float) $product->member_price,
'wholesale_price' => (float) $product->wholesale_price,
],
'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]),
]);
}
/**
@@ -172,50 +192,31 @@ class ProductController extends Controller
public function update(Request $request, Product $product)
{
$validated = $request->validate([
'code' => 'required|string|min:2|max:8|unique:products,code,' . $product->id,
'barcode' => 'required|string|unique:products,barcode,' . $product->id,
'code' => 'nullable|unique:products,code,' . $product->id,
'barcode' => 'nullable|unique:products,barcode,' . $product->id,
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string',
'base_unit_id' => 'required|exists:units,id',
'large_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
'purchase_unit_id' => 'nullable|exists:units,id',
'purchase_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'nullable|numeric|min:0',
'location' => 'nullable|string|max:255',
'cost_price' => 'nullable|numeric|min:0',
'price' => 'nullable|numeric|min:0',
'member_price' => 'nullable|numeric|min:0',
'wholesale_price' => 'nullable|numeric|min:0',
], [
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 8 碼',
'code.min' => '商品代號最少 2 碼',
'code.unique' => '商品代號已存在',
'barcode.required' => '條碼編號為必填',
'barcode.unique' => '條碼編號已存在',
'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在',
'base_unit_id.required' => '基本庫存單位為必填',
'base_unit_id.exists' => '所選基本單位不存在',
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
'conversion_rate.numeric' => '換算率必須為數字',
'conversion_rate.min' => '換算率最小為 0.0001',
'cost_price.numeric' => '成本價必須為數字',
'cost_price.min' => '成本價不能小於 0',
'price.numeric' => '售價必須為數字',
'price.min' => '售價不能小於 0',
'member_price.numeric' => '會員價必須為數字',
'member_price.min' => '會員價不能小於 0',
'wholesale_price.numeric' => '批發價必須為數字',
'wholesale_price.min' => '批發價不能小於 0',
'is_active' => 'boolean',
]);
if (empty($validated['code'])) {
$validated['code'] = $this->generateRandomCode();
}
$product->update($validated);
return redirect()->back()->with('success', '商品已更新');
return redirect()->route('products.index')->with('success', '商品已更新');
}
/**
@@ -259,4 +260,22 @@ class ProductController extends Controller
return redirect()->back()->withErrors(['file' => '匯入失敗: ' . $e->getMessage()]);
}
}
/**
* 生成隨機 8 碼代號 (大寫英文+數字)
*/
private function generateRandomCode(): string
{
$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$code = '';
do {
$code = '';
for ($i = 0; $i < 8; $i++) {
$code .= $characters[rand(0, strlen($characters) - 1)];
}
} while (Product::where('code', $code)->exists());
return $code;
}
}

View File

@@ -55,6 +55,19 @@ class WarehouseController extends Controller
->orWhere('expiry_date', '>=', now());
});
}], 'total_value')
->withSum(['inventories as abnormal_amount' => function ($query) {
$query->where('quantity', '>', 0)
->where(function ($q) {
$q->where('quality_status', '!=', 'normal')
->orWhere(function ($sq) {
$sq->whereNotNull('expiry_date')
->where('expiry_date', '<', now());
})
->orWhereHas('warehouse', function ($wq) {
$wq->where('type', \App\Enums\WarehouseType::QUARANTINE);
});
});
}], 'total_value')
->addSelect(['low_stock_count' => function ($query) {
$query->selectRaw('count(*)')
->from('warehouse_product_safety_stocks as ss')
@@ -85,6 +98,17 @@ class WarehouseController extends Controller
$q->whereNull('expiry_date')
->orWhere('expiry_date', '>=', now());
})->sum('total_value'),
'abnormal_amount' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0)
->where(function ($q) {
$q->where('quality_status', '!=', 'normal')
->orWhere(function ($sq) {
$sq->whereNotNull('expiry_date')
->where('expiry_date', '<', now());
})
->orWhereHas('warehouse', function ($wq) {
$wq->where('type', \App\Enums\WarehouseType::QUARANTINE);
});
})->sum('total_value'),
'book_stock' => \App\Modules\Inventory\Models\Inventory::sum('quantity'),
'book_amount' => \App\Modules\Inventory\Models\Inventory::sum('total_value'),
];

View File

@@ -63,8 +63,14 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
return null;
}
// 處理商品代號:若為空則自動生成
$code = $row['商品代號'] ?? null;
if (empty($code)) {
$code = $this->generateRandomCode();
}
return new Product([
'code' => $row['商品代號'],
'code' => $code,
'barcode' => $row['條碼'],
'name' => $row['商品名稱'],
'category_id' => $categoryId,
@@ -81,10 +87,28 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
]);
}
/**
* 生成隨機 8 碼代號 (大寫英文+數字)
*/
private function generateRandomCode(): string
{
$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$code = '';
do {
$code = '';
for ($i = 0; $i < 8; $i++) {
$code .= $characters[rand(0, strlen($characters) - 1)];
}
} while (Product::where('code', $code)->exists());
return $code;
}
public function rules(): array
{
return [
'商品代號' => ['required', 'string', 'min:2', 'max:8', 'unique:products,code'],
'商品代號' => ['nullable', 'string', 'min:2', 'max:8', 'unique:products,code'],
'條碼' => ['required', 'string', 'unique:products,barcode'],
'商品名稱' => ['required', 'string'],
'類別名稱' => ['required', function($attribute, $value, $fail) {

View File

@@ -33,6 +33,8 @@ Route::middleware('auth')->group(function () {
Route::get('/products/template', [ProductController::class, 'template'])->name('products.template');
Route::post('/products/import', [ProductController::class, 'import'])->middleware('permission:products.create')->name('products.import');
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
Route::get('/products/create', [ProductController::class, 'create'])->middleware('permission:products.create')->name('products.create');
Route::get('/products/{product}/edit', [ProductController::class, 'edit'])->middleware('permission:products.edit')->name('products.edit');
Route::post('/products', [ProductController::class, 'store'])->middleware('permission:products.create')->name('products.store');
Route::put('/products/{product}', [ProductController::class, 'update'])->middleware('permission:products.edit')->name('products.update');
Route::delete('/products/{product}', [ProductController::class, 'destroy'])->middleware('permission:products.delete')->name('products.destroy');