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

@@ -0,0 +1,100 @@
<?php
namespace App\Modules\Inventory\Contracts;
interface InventoryServiceInterface
{
/**
* Check if a product has sufficient stock in a specific warehouse.
*
* @param int $productId
* @param int $warehouseId
* @param float $quantity
* @return bool
*/
public function checkStock(int $productId, int $warehouseId, float $quantity): bool;
/**
* Decrease stock for a product (e.g., when an order is placed).
*
* @param int $productId
* @param int $warehouseId
* @param float $quantity
* @param string|null $reason
* @return void
*/
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null): void;
/**
* Get all active warehouses.
*
* @return \Illuminate\Support\Collection
*/
public function getAllWarehouses();
/**
* Get multiple products by their IDs.
*
* @param array $ids
* @return \Illuminate\Support\Collection
*/
public function getProductsByIds(array $ids);
/**
* Get a specific product by ID.
*
* @param int $id
* @return object|null
*/
public function getProduct(int $id);
/**
* Get a specific warehouse by ID.
*
* @param int $id
* @return object|null
*/
public function getWarehouse(int $id);
/**
* Get all available inventories in a specific warehouse.
*
* @param int $warehouseId
* @return \Illuminate\Support\Collection
*/
public function getInventoriesByWarehouse(int $warehouseId);
/**
* Get all products.
*
* @return \Illuminate\Support\Collection
*/
public function getAllProducts();
/**
* Get all units.
*
* @return \Illuminate\Support\Collection
*/
public function getUnits();
/**
* Create a new inventory record (e.g., for finished goods).
*
* @param array $data
* @return object
*/
public function createInventoryRecord(array $data);
/**
* Decrease quantity of a specific inventory record.
*
* @param int $inventoryId
* @param float $quantity
* @param string|null $reason
* @param string|null $referenceType
* @param int|string|null $referenceId
* @return void
*/
public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null);
}

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

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Modules\Inventory;
use Illuminate\Support\ServiceProvider;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Services\InventoryService;
class InventoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
}
public function boot(): void
{
//
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Procurement\Models\PurchaseOrder; // Cross-module dependency
class Inventory extends Model
{
@@ -35,8 +35,8 @@ class Inventory extends Model
];
/**
* Transient property to store the reason for the activity log (e.g., "Replenishment #123").
* This is not stored in the database column but used for logging context.
* 用於活動記錄的暫時屬性(例如 "補貨 #123")。
* 此屬性不存儲在資料庫欄位中,但用於記錄上下文。
* @var string|null
*/
public $activityLogReason;
@@ -55,12 +55,12 @@ class Inventory extends Model
$attributes = $properties['attributes'] ?? [];
$snapshot = $properties['snapshot'] ?? [];
// Always snapshot names for context, even if IDs didn't change
// $this refers to the Inventory model instance
// 始終對名稱進行快照以便於上下文顯示,即使 ID 未更改
// $this 指的是 Inventory 模型實例
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : ($snapshot['warehouse_name'] ?? null);
$snapshot['product_name'] = $this->product ? $this->product->name : ($snapshot['product_name'] ?? null);
// Capture the reason if set
// 如果已設定原因,則進行捕捉
if ($this->activityLogReason) {
$attributes['_reason'] = $this->activityLogReason;
}
@@ -105,13 +105,7 @@ class Inventory extends Model
});
}
/**
* 來源採購單
*/
public function sourcePurchaseOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(PurchaseOrder::class, 'source_purchase_order_id');
}
/**
* 產生批號

View File

@@ -4,7 +4,7 @@ namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Core\Models\User; // Cross-module Core dependency
use App\Modules\Core\Models\User; // 跨模組核心依賴
class InventoryTransaction extends Model
{

View File

@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use App\Modules\Procurement\Models\Vendor; // Cross-module dependency (Procurement)
class Product extends Model
{
@@ -32,7 +32,7 @@ class Product extends Model
];
/**
* Get the category that owns the product.
* 取得該商品所屬的分類。
*/
public function category(): BelongsTo
{
@@ -54,10 +54,7 @@ class Product extends Model
return $this->belongsTo(Unit::class, 'purchase_unit_id');
}
public function vendors(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Vendor::class)->withPivot('last_price')->withTimestamps();
}
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
{
@@ -83,13 +80,13 @@ class Product extends Model
$attributes = $properties['attributes'] ?? [];
$snapshot = $properties['snapshot'] ?? [];
// Handle Category Name Snapshot
// 處理分類名稱快照
if (isset($attributes['category_id'])) {
$category = Category::find($attributes['category_id']);
$snapshot['category_name'] = $category ? $category->name : null;
}
// Handle Unit Name Snapshots
// 處理單位名稱快照
$unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id'];
foreach ($unitFields as $field) {
if (isset($attributes[$field])) {
@@ -99,7 +96,7 @@ class Product extends Model
}
}
// Always snapshot self name for context (so logs always show "Cola")
// 始終對自身名稱進行快照以便於上下文顯示(這樣日誌總是顯示 "可樂"
$snapshot['name'] = $this->name;
$properties['attributes'] = $attributes;

View File

@@ -4,7 +4,7 @@ namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Procurement\Models\PurchaseOrder; // Cross-module dependency (Procurement)
class Warehouse extends Model
{
@@ -17,6 +17,11 @@ class Warehouse extends Model
'name',
'address',
'description',
'is_sellable',
];
protected $casts = [
'is_sellable' => 'boolean',
];
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
@@ -43,10 +48,7 @@ class Warehouse extends Model
return $this->hasMany(Inventory::class);
}
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(PurchaseOrder::class);
}
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Product;
use Illuminate\Support\Facades\DB;
class InventoryService implements InventoryServiceInterface
{
public function getAllWarehouses()
{
return Warehouse::all();
}
public function getAllProducts()
{
return Product::with(['baseUnit'])->get();
}
public function getUnits()
{
return \App\Modules\Inventory\Models\Unit::all();
}
public function getInventoriesByIds(array $ids, array $with = [])
{
return Inventory::whereIn('id', $ids)->with($with)->get();
}
public function getProduct(int $id)
{
return Product::find($id);
}
public function getProductsByIds(array $ids)
{
return Product::whereIn('id', $ids)->get();
}
public function getWarehouse(int $id)
{
return Warehouse::find($id);
}
public function checkStock(int $productId, int $warehouseId, float $quantity): bool
{
$stock = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->sum('quantity');
return $stock >= $quantity;
}
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null): void
{
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason) {
$inventories = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->where('quantity', '>', 0)
->orderBy('arrival_date', 'asc')
->get();
$remainingToDecrease = $quantity;
foreach ($inventories as $inventory) {
if ($remainingToDecrease <= 0) break;
$decreaseAmount = min($inventory->quantity, $remainingToDecrease);
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason);
$remainingToDecrease -= $decreaseAmount;
}
if ($remainingToDecrease > 0) {
// 這裡可以選擇報錯或允許負庫存,目前為了嚴謹拋出異常
throw new \Exception("庫存不足,無法扣除所有請求的數量。");
}
});
}
public function getInventoriesByWarehouse(int $warehouseId)
{
return Inventory::with(['product.baseUnit', 'product.largeUnit'])
->where('warehouse_id', $warehouseId)
->where('quantity', '>', 0)
->orderBy('arrival_date', 'asc')
->get();
}
public function createInventoryRecord(array $data)
{
return DB::transaction(function () use ($data) {
// 嘗試查找是否已有相同批號的庫存
$inventory = Inventory::where('warehouse_id', $data['warehouse_id'])
->where('product_id', $data['product_id'])
->where('batch_number', $data['batch_number'] ?? null)
->first();
$balanceBefore = 0;
if ($inventory) {
// 若存在,則更新數量與相關資訊 (鎖定行以避免併發問題)
$inventory = Inventory::lockForUpdate()->find($inventory->id);
$balanceBefore = $inventory->quantity;
$inventory->quantity += $data['quantity'];
// 更新其他可能變更的欄位 (如最後入庫日)
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
$inventory->save();
} else {
// 若不存在,則建立新紀錄
$inventory = Inventory::create([
'warehouse_id' => $data['warehouse_id'],
'product_id' => $data['product_id'],
'quantity' => $data['quantity'],
'batch_number' => $data['batch_number'] ?? null,
'box_number' => $data['box_number'] ?? null,
'origin_country' => $data['origin_country'] ?? 'TW',
'arrival_date' => $data['arrival_date'] ?? now(),
'expiry_date' => $data['expiry_date'] ?? null,
'quality_status' => $data['quality_status'] ?? 'normal',
'source_purchase_order_id' => $data['source_purchase_order_id'] ?? null,
]);
}
\App\Modules\Inventory\Models\InventoryTransaction::create([
'inventory_id' => $inventory->id,
'type' => '入庫',
'quantity' => $data['quantity'],
'balance_before' => $balanceBefore,
'balance_after' => $inventory->quantity,
'reason' => $data['reason'] ?? '手動入庫',
'reference_type' => $data['reference_type'] ?? null,
'reference_id' => $data['reference_id'] ?? null,
'user_id' => auth()->id(),
'actual_time' => now(),
]);
return $inventory;
});
}
public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null): void
{
DB::transaction(function () use ($inventoryId, $quantity, $reason, $referenceType, $referenceId) {
$inventory = Inventory::lockForUpdate()->findOrFail($inventoryId);
$balanceBefore = $inventory->quantity;
$inventory->decrement('quantity', $quantity);
$inventory->refresh();
\App\Modules\Inventory\Models\InventoryTransaction::create([
'inventory_id' => $inventory->id,
'type' => '出庫',
'quantity' => -$quantity,
'balance_before' => $balanceBefore,
'balance_after' => $inventory->quantity,
'reason' => $reason ?? '庫存扣減',
'reference_type' => $referenceType,
'reference_id' => $referenceId,
'user_id' => auth()->id(),
'actual_time' => now(),
]);
});
}
}