620 lines
27 KiB
PHP
620 lines
27 KiB
PHP
<?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 App\Modules\Inventory\Models\InventoryTransferOrder;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
class InventoryService implements InventoryServiceInterface
|
||
{
|
||
public function getAllWarehouses()
|
||
{
|
||
return Warehouse::all();
|
||
}
|
||
|
||
public function getAllProducts()
|
||
{
|
||
return Product::with(['baseUnit', 'largeUnit'])->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::with(['baseUnit', 'largeUnit'])->find($id);
|
||
}
|
||
|
||
public function getProductsByIds(array $ids)
|
||
{
|
||
return Product::whereIn('id', $ids)->with(['baseUnit', 'largeUnit'])->get();
|
||
}
|
||
|
||
public function getProductsByName(string $name)
|
||
{
|
||
return Product::where('name', 'like', "%{$name}%")->with(['baseUnit', 'largeUnit'])->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, bool $force = false, ?string $slot = null): void
|
||
{
|
||
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot) {
|
||
$query = Inventory::where('product_id', $productId)
|
||
->where('warehouse_id', $warehouseId)
|
||
->where('quantity', '>', 0);
|
||
|
||
if ($slot) {
|
||
$query->where('location', $slot);
|
||
}
|
||
|
||
$inventories = $query->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) {
|
||
if ($force) {
|
||
// Find any existing inventory record in this warehouse/slot to subtract from, or create one
|
||
$query = Inventory::where('product_id', $productId)
|
||
->where('warehouse_id', $warehouseId);
|
||
|
||
if ($slot) {
|
||
$query->where('location', $slot);
|
||
}
|
||
|
||
$inventory = $query->first();
|
||
|
||
if (!$inventory) {
|
||
$inventory = Inventory::create([
|
||
'warehouse_id' => $warehouseId,
|
||
'product_id' => $productId,
|
||
'location' => $slot,
|
||
'quantity' => 0,
|
||
'unit_cost' => 0,
|
||
'total_value' => 0,
|
||
'batch_number' => 'POS-AUTO-' . ($slot ? $slot . '-' : '') . time(),
|
||
'arrival_date' => now(),
|
||
'origin_country' => 'TW',
|
||
'quality_status' => 'normal',
|
||
]);
|
||
}
|
||
|
||
$this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason);
|
||
} else {
|
||
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;
|
||
|
||
// 加權平均成本計算 (可選,這裡先採簡單邏輯:若有新成本則更新,否則沿用)
|
||
// 若本次入庫有指定成本,則更新該批次單價 (假設同批號成本相同)
|
||
if (isset($data['unit_cost'])) {
|
||
$inventory->unit_cost = $data['unit_cost'];
|
||
}
|
||
|
||
$inventory->quantity += $data['quantity'];
|
||
// 更新總價值
|
||
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||
|
||
// 更新其他可能變更的欄位 (如最後入庫日)
|
||
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
|
||
$inventory->save();
|
||
} else {
|
||
// 若不存在,則建立新紀錄
|
||
$unitCost = $data['unit_cost'] ?? 0;
|
||
$inventory = Inventory::create([
|
||
'warehouse_id' => $data['warehouse_id'],
|
||
'product_id' => $data['product_id'],
|
||
'quantity' => $data['quantity'],
|
||
'unit_cost' => $unitCost,
|
||
'total_value' => $data['quantity'] * $unitCost,
|
||
'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'],
|
||
'unit_cost' => $inventory->unit_cost, // 記錄當下成本
|
||
'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); // decrement 不會自動觸發 total_value 更新
|
||
// 需要手動更新總價值
|
||
$inventory->refresh();
|
||
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||
$inventory->save();
|
||
|
||
\App\Modules\Inventory\Models\InventoryTransaction::create([
|
||
'inventory_id' => $inventory->id,
|
||
'type' => '出庫',
|
||
'quantity' => -$quantity,
|
||
'unit_cost' => $inventory->unit_cost, // 記錄出庫時的成本
|
||
'balance_before' => $balanceBefore,
|
||
'balance_after' => $inventory->quantity,
|
||
'reason' => $reason ?? '庫存扣減',
|
||
'reference_type' => $referenceType,
|
||
'reference_id' => $referenceId,
|
||
'user_id' => auth()->id(),
|
||
'actual_time' => now(),
|
||
]);
|
||
});
|
||
}
|
||
|
||
public function findInventoryByBatch(int $warehouseId, int $productId, ?string $batchNumber)
|
||
{
|
||
return Inventory::where('warehouse_id', $warehouseId)
|
||
->where('product_id', $productId)
|
||
->where('batch_number', $batchNumber)
|
||
->first();
|
||
}
|
||
|
||
/**
|
||
* 即時庫存查詢:統計卡片 + 分頁明細
|
||
*/
|
||
public function getStockQueryData(array $filters = [], int $perPage = 10): array
|
||
{
|
||
$today = now()->toDateString();
|
||
$expiryThreshold = now()->addDays(30)->toDateString();
|
||
|
||
// 基礎查詢
|
||
$query = Inventory::query()
|
||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||
->join('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id')
|
||
->leftJoin('categories', 'products.category_id', '=', 'categories.id')
|
||
->leftJoin('warehouse_product_safety_stocks as ss', function ($join) {
|
||
$join->on('inventories.warehouse_id', '=', 'ss.warehouse_id')
|
||
->on('inventories.product_id', '=', 'ss.product_id');
|
||
})
|
||
->whereNull('inventories.deleted_at')
|
||
->select([
|
||
'inventories.id',
|
||
'inventories.warehouse_id',
|
||
'inventories.product_id',
|
||
'inventories.quantity',
|
||
'inventories.batch_number',
|
||
'inventories.expiry_date',
|
||
'inventories.location',
|
||
'inventories.quality_status',
|
||
'products.code as product_code',
|
||
'products.name as product_name',
|
||
'categories.name as category_name',
|
||
'warehouses.name as warehouse_name',
|
||
'ss.safety_stock',
|
||
]);
|
||
|
||
// 篩選:倉庫
|
||
if (!empty($filters['warehouse_id'])) {
|
||
$query->where('inventories.warehouse_id', $filters['warehouse_id']);
|
||
}
|
||
|
||
// 篩選:分類
|
||
if (!empty($filters['category_id'])) {
|
||
$query->where('products.category_id', $filters['category_id']);
|
||
}
|
||
|
||
// 篩選:關鍵字(商品代碼或名稱)
|
||
if (!empty($filters['search'])) {
|
||
$search = $filters['search'];
|
||
$query->where(function ($q) use ($search) {
|
||
$q->where('products.code', 'like', "%{$search}%")
|
||
->orWhere('products.name', 'like', "%{$search}%");
|
||
});
|
||
}
|
||
|
||
// 篩選:狀態 (改為對齊聚合統計的判斷標準)
|
||
if (!empty($filters['status'])) {
|
||
switch ($filters['status']) {
|
||
case 'low_stock':
|
||
$query->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) {
|
||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||
->from('inventories as i2')
|
||
->join('warehouse_product_safety_stocks as ss2', function ($join) {
|
||
$join->on('i2.warehouse_id', '=', 'ss2.warehouse_id')
|
||
->on('i2.product_id', '=', 'ss2.product_id');
|
||
})
|
||
->whereNull('i2.deleted_at')
|
||
->groupBy('i2.warehouse_id', 'i2.product_id', 'ss2.safety_stock')
|
||
->havingRaw('SUM(i2.quantity) <= ss2.safety_stock');
|
||
});
|
||
break;
|
||
case 'negative':
|
||
$query->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) {
|
||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||
->from('inventories as i2')
|
||
->whereNull('i2.deleted_at')
|
||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||
->havingRaw('SUM(i2.quantity) < 0');
|
||
});
|
||
break;
|
||
case 'expiring':
|
||
$query->whereNotNull('inventories.expiry_date')
|
||
->where('inventories.expiry_date', '>', $today)
|
||
->where('inventories.expiry_date', '<=', $expiryThreshold);
|
||
break;
|
||
case 'expired':
|
||
$query->whereNotNull('inventories.expiry_date')
|
||
->where('inventories.expiry_date', '<=', $today);
|
||
break;
|
||
case 'abnormal':
|
||
// 只要該「倉庫-品項」對應的總庫存有低庫存、負庫存,或該批次已過期/即將過期
|
||
$query->where(function ($q) use ($today, $expiryThreshold) {
|
||
// 1. 低庫存或負庫存 (依聚合判斷)
|
||
$q->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) {
|
||
$sub->select('i3.warehouse_id', 'i3.product_id')
|
||
->from('inventories as i3')
|
||
->leftJoin('warehouse_product_safety_stocks as ss3', function ($join) {
|
||
$join->on('i3.warehouse_id', '=', 'ss3.warehouse_id')
|
||
->on('i3.product_id', '=', 'ss3.product_id');
|
||
})
|
||
->whereNull('i3.deleted_at')
|
||
->groupBy('i3.warehouse_id', 'i3.product_id', 'ss3.safety_stock')
|
||
->havingRaw('SUM(i3.quantity) < 0 OR (ss3.safety_stock IS NOT NULL AND SUM(i3.quantity) <= ss3.safety_stock)');
|
||
})
|
||
// 2. 或該批次效期異常
|
||
->orWhere(function ($q_batch) use ($expiryThreshold) {
|
||
$q_batch->whereNotNull('inventories.expiry_date')
|
||
->where('inventories.expiry_date', '<=', $expiryThreshold);
|
||
});
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 排序
|
||
$sortBy = $filters['sort_by'] ?? 'products.code';
|
||
$sortOrder = $filters['sort_order'] ?? 'asc';
|
||
$allowedSorts = ['products.code', 'products.name', 'warehouses.name', 'inventories.quantity', 'inventories.expiry_date'];
|
||
if (in_array($sortBy, $allowedSorts)) {
|
||
$query->orderBy($sortBy, $sortOrder);
|
||
} else {
|
||
$query->orderBy('products.code', 'asc');
|
||
}
|
||
|
||
// 統計卡片(預設無篩選條件下的全域統計,改為明細筆數計數以對齊顯示)
|
||
// 1. 庫存明細總數
|
||
$totalItems = DB::table('inventories')
|
||
->whereNull('deleted_at')
|
||
->count();
|
||
|
||
// 2. 低庫存明細數:只要該明細所屬的「倉庫+商品」總量低於安全庫存,則所有相關明細都計入
|
||
$lowStockCount = DB::table('inventories as i')
|
||
->join('warehouse_product_safety_stocks as ss', function ($join) {
|
||
$join->on('i.warehouse_id', '=', 'ss.warehouse_id')
|
||
->on('i.product_id', '=', 'ss.product_id');
|
||
})
|
||
->whereNull('i.deleted_at')
|
||
->whereIn(DB::raw('(i.warehouse_id, i.product_id)'), function ($sub) {
|
||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||
->from('inventories as i2')
|
||
->whereNull('i2.deleted_at')
|
||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||
->havingRaw('SUM(i2.quantity) <= (SELECT safety_stock FROM warehouse_product_safety_stocks WHERE warehouse_id = i2.warehouse_id AND product_id = i2.product_id LIMIT 1)');
|
||
})
|
||
->count();
|
||
|
||
// 3. 負庫存明細數
|
||
$negativeCount = DB::table('inventories as i')
|
||
->whereNull('i.deleted_at')
|
||
->whereIn(DB::raw('(i.warehouse_id, i.product_id)'), function ($sub) {
|
||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||
->from('inventories as i2')
|
||
->whereNull('i2.deleted_at')
|
||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||
->havingRaw('SUM(i2.quantity) < 0');
|
||
})
|
||
->count();
|
||
|
||
// 4. 即將過期明細數 (必須排除已過期)
|
||
$expiringCount = DB::table('inventories')
|
||
->whereNull('deleted_at')
|
||
->whereNotNull('expiry_date')
|
||
->where('expiry_date', '>', $today)
|
||
->where('expiry_date', '<=', $expiryThreshold)
|
||
->count();
|
||
|
||
// 分頁
|
||
$paginated = $query->paginate($perPage)->withQueryString();
|
||
|
||
// 為每筆紀錄附加最後入庫/出庫時間 + 狀態
|
||
$items = collect($paginated->items())->map(function ($item) use ($today, $expiryThreshold) {
|
||
$lastIn = \App\Modules\Inventory\Models\InventoryTransaction::where('inventory_id', $item->id)
|
||
->where('type', '入庫')
|
||
->orderByDesc('actual_time')
|
||
->value('actual_time');
|
||
|
||
$lastOut = \App\Modules\Inventory\Models\InventoryTransaction::where('inventory_id', $item->id)
|
||
->where('type', '出庫')
|
||
->orderByDesc('actual_time')
|
||
->value('actual_time');
|
||
|
||
// 計算狀態
|
||
$statuses = [];
|
||
if ($item->quantity < 0) {
|
||
$statuses[] = 'negative';
|
||
}
|
||
if ($item->safety_stock !== null && $item->quantity <= $item->safety_stock && $item->quantity >= 0) {
|
||
$statuses[] = 'low_stock';
|
||
}
|
||
if ($item->expiry_date) {
|
||
if ($item->expiry_date <= $today) {
|
||
$statuses[] = 'expired';
|
||
} elseif ($item->expiry_date <= $expiryThreshold) {
|
||
$statuses[] = 'expiring';
|
||
}
|
||
}
|
||
if (empty($statuses)) {
|
||
$statuses[] = 'normal';
|
||
}
|
||
|
||
return [
|
||
'id' => $item->id,
|
||
'product_code' => $item->product_code,
|
||
'product_name' => $item->product_name,
|
||
'category_name' => $item->category_name,
|
||
'warehouse_name' => $item->warehouse_name,
|
||
'batch_number' => $item->batch_number,
|
||
'quantity' => $item->quantity,
|
||
'safety_stock' => $item->safety_stock,
|
||
'expiry_date' => $item->expiry_date ? \Carbon\Carbon::parse($item->expiry_date)->toDateString() : null,
|
||
'location' => $item->location,
|
||
'quality_status' => $item->quality_status ?? null,
|
||
'last_inbound' => $lastIn ? \Carbon\Carbon::parse($lastIn)->toDateString() : null,
|
||
'last_outbound' => $lastOut ? \Carbon\Carbon::parse($lastOut)->toDateString() : null,
|
||
'statuses' => $statuses,
|
||
];
|
||
});
|
||
|
||
return [
|
||
'summary' => [
|
||
'totalItems' => $totalItems,
|
||
'lowStockCount' => $lowStockCount,
|
||
'negativeCount' => $negativeCount,
|
||
'expiringCount' => $expiringCount,
|
||
],
|
||
'data' => $items->toArray(),
|
||
'pagination' => [
|
||
'total' => $paginated->total(),
|
||
'per_page' => $paginated->perPage(),
|
||
'current_page' => $paginated->currentPage(),
|
||
'last_page' => $paginated->lastPage(),
|
||
'links' => $paginated->linkCollection()->toArray(),
|
||
],
|
||
];
|
||
}
|
||
|
||
public function getDashboardStats(): array
|
||
{
|
||
$today = now()->toDateString();
|
||
$expiryThreshold = now()->addDays(30)->toDateString();
|
||
|
||
// 1. 庫存品項數 (明細總數)
|
||
$totalItems = DB::table('inventories')
|
||
->whereNull('deleted_at')
|
||
->count();
|
||
|
||
// 2. 低庫存 (明細計數:只要該明細所屬的「倉庫+商品」總量低於安全庫存,則所有相關明細都計入)
|
||
$lowStockCount = DB::table('inventories as i')
|
||
->join('warehouse_product_safety_stocks as ss', function ($join) {
|
||
$join->on('i.warehouse_id', '=', 'ss.warehouse_id')
|
||
->on('i.product_id', '=', 'ss.product_id');
|
||
})
|
||
->whereNull('i.deleted_at')
|
||
->whereIn(DB::raw('(i.warehouse_id, i.product_id)'), function ($sub) {
|
||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||
->from('inventories as i2')
|
||
->whereNull('i2.deleted_at')
|
||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||
->havingRaw('SUM(i2.quantity) <= (SELECT safety_stock FROM warehouse_product_safety_stocks WHERE warehouse_id = i2.warehouse_id AND product_id = i2.product_id LIMIT 1)');
|
||
})
|
||
->count();
|
||
|
||
// 3. 負庫存 (明細計數)
|
||
$negativeCount = DB::table('inventories as i')
|
||
->whereNull('i.deleted_at')
|
||
->whereIn(DB::raw('(i.warehouse_id, i.product_id)'), function ($sub) {
|
||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||
->from('inventories as i2')
|
||
->whereNull('i2.deleted_at')
|
||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||
->havingRaw('SUM(i2.quantity) < 0');
|
||
})
|
||
->count();
|
||
|
||
// 4. 即將過期 (明細計數)
|
||
$expiringCount = DB::table('inventories')
|
||
->whereNull('deleted_at')
|
||
->whereNotNull('expiry_date')
|
||
->where('expiry_date', '>', $today) // 確保不過期 (getStockQueryData 沒加這個但這裡加上以防與 expired 混淆? 不,stock query 是 > today && <= threshold)
|
||
->where('expiry_date', '<=', $expiryThreshold)
|
||
->count();
|
||
|
||
// 異常庫存前 10 筆 (明細面依然以個別批次為主,供快速跳轉)
|
||
$abnormalItems = Inventory::query()
|
||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||
->join('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id')
|
||
->leftJoin('warehouse_product_safety_stocks as ss', function ($join) {
|
||
$join->on('inventories.warehouse_id', '=', 'ss.warehouse_id')
|
||
->on('inventories.product_id', '=', 'ss.product_id');
|
||
})
|
||
->whereNull('inventories.deleted_at')
|
||
->where(function ($q) use ($today, $expiryThreshold) {
|
||
// 1. 屬於低庫存或負庫存品項的批次
|
||
$q->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) {
|
||
$sub->select('i3.warehouse_id', 'i3.product_id')
|
||
->from('inventories as i3')
|
||
->leftJoin('warehouse_product_safety_stocks as ss3', function ($join) {
|
||
$join->on('i3.warehouse_id', '=', 'ss3.warehouse_id')
|
||
->on('i3.product_id', '=', 'ss3.product_id');
|
||
})
|
||
->whereNull('i3.deleted_at')
|
||
->groupBy('i3.warehouse_id', 'i3.product_id', 'ss3.safety_stock')
|
||
->havingRaw('SUM(i3.quantity) < 0 OR (ss3.safety_stock IS NOT NULL AND SUM(i3.quantity) <= ss3.safety_stock)');
|
||
})
|
||
// 2. 或單一批次效期異常
|
||
->orWhere(function ($q2) use ($expiryThreshold) {
|
||
$q2->whereNotNull('inventories.expiry_date')
|
||
->where('inventories.expiry_date', '<=', $expiryThreshold);
|
||
});
|
||
})
|
||
->select([
|
||
'inventories.id',
|
||
'inventories.quantity',
|
||
'inventories.expiry_date',
|
||
'products.code as product_code',
|
||
'products.name as product_name',
|
||
'warehouses.name as warehouse_name',
|
||
'ss.safety_stock',
|
||
])
|
||
->orderBy('inventories.id', 'desc')
|
||
->limit(10)
|
||
->get()
|
||
->map(function ($item) use ($today, $expiryThreshold) {
|
||
$statuses = [];
|
||
if ($item->quantity < 0) {
|
||
$statuses[] = 'negative';
|
||
}
|
||
if ($item->safety_stock !== null && $item->quantity <= $item->safety_stock && $item->quantity >= 0) {
|
||
$statuses[] = 'low_stock';
|
||
}
|
||
if ($item->expiry_date) {
|
||
if ($item->expiry_date <= $today) {
|
||
$statuses[] = 'expired';
|
||
} elseif ($item->expiry_date <= $expiryThreshold) {
|
||
$statuses[] = 'expiring';
|
||
}
|
||
}
|
||
return [
|
||
'id' => $item->id,
|
||
'product_code' => $item->product_code,
|
||
'product_name' => $item->product_name,
|
||
'warehouse_name' => $item->warehouse_name,
|
||
'quantity' => $item->quantity,
|
||
'safety_stock' => $item->safety_stock,
|
||
'expiry_date' => $item->expiry_date,
|
||
'statuses' => $statuses,
|
||
];
|
||
})
|
||
->toArray();
|
||
|
||
return [
|
||
'productsCount' => $totalItems,
|
||
'warehousesCount' => Warehouse::count(),
|
||
'lowStockCount' => $lowStockCount,
|
||
'negativeCount' => $negativeCount,
|
||
'expiringCount' => $expiringCount,
|
||
'totalInventoryQuantity' => Inventory::sum('quantity'),
|
||
'totalInventoryValue' => Inventory::sum('total_value'),
|
||
'pendingTransferCount' => InventoryTransferOrder::whereIn('status', ['draft', 'dispatched'])->count(), // 新增:待處理調撥單
|
||
'abnormalItems' => $abnormalItems,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 依倉庫名稱查找或建立倉庫(供外部整合用)。
|
||
*
|
||
* @param string $warehouseName
|
||
* @return Warehouse
|
||
*/
|
||
public function findOrCreateWarehouseByName(string $warehouseName)
|
||
{
|
||
// 1. 優先查找名稱完全匹配的倉庫(不限類型)
|
||
$warehouse = Warehouse::where('name', $warehouseName)
|
||
->first();
|
||
|
||
if ($warehouse) {
|
||
return $warehouse;
|
||
}
|
||
|
||
// 2. 若找不到對應倉庫,則統一進入「整合銷售倉」(類型:retail)
|
||
return Warehouse::firstOrCreate(
|
||
['name' => '整合銷售倉'],
|
||
[
|
||
'code' => 'INT-RETAIL-001',
|
||
'type' => 'retail',
|
||
]
|
||
);
|
||
}
|
||
}
|