Files
star-erp/app/Modules/Inventory/Services/InventoryService.php
sky121113 77a7d31dc1
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 54s
fix(dashboard): 修正儀表板待處理數字邏輯與依賴更新
2026-02-13 14:29:21 +08:00

595 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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,
];
}
}