Files
star-erp/app/Modules/Inventory/Services/InventoryService.php

605 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 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');
// 決定是否啟用聚合顯示 (Group By Warehouse + Product)
// 當使用者在儀表板點選「狀態篩選」時,為了讓清單筆數與統計數字精確一致 (Dimension Alignment),必須啟用 GROUP BY
$isGrouped = !empty($filters['status']) || ($filters['view_mode'] ?? '') === 'product';
if ($isGrouped) {
$query->select([
DB::raw('MIN(inventories.id) as id'), // 聚合後的代表 ID
'inventories.warehouse_id',
'inventories.product_id',
DB::raw('SUM(inventories.quantity) as quantity'),
DB::raw('GROUP_CONCAT(DISTINCT inventories.batch_number SEPARATOR ", ") as batch_number'),
DB::raw('MAX(inventories.expiry_date) as expiry_date'),
DB::raw('GROUP_CONCAT(DISTINCT inventories.location SEPARATOR ", ") as location'),
'products.code as product_code',
'products.name as product_name',
'categories.name as category_name',
'warehouses.name as warehouse_name',
'ss.safety_stock',
])->groupBy(
'inventories.warehouse_id',
'inventories.product_id',
'products.code',
'products.name',
'categories.name',
'warehouses.name',
'ss.safety_stock'
);
} else {
$query->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')
->distinct()
->count(DB::raw('CONCAT(warehouse_id, "-", product_id)'));
// 2. 低庫存:以「倉庫-商品」聚合後的總量與安全庫存比較 (品項計數)
$lowStockCount = DB::table('warehouse_product_safety_stocks as ss')
->join(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'),
function ($join) {
$join->on('ss.warehouse_id', '=', 'inv.warehouse_id')
->on('ss.product_id', '=', 'inv.product_id');
})
->whereRaw('inv.total_qty <= ss.safety_stock')
->count();
// 3. 負庫存:只要該「倉庫-商品」的總庫存為負數 (品項計數)
$negativeCount = DB::table(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'))
->where('total_qty', '<', 0)
->count();
// 4. 即將過期:有任一批次效期符合的「品項」總數
$expiringCount = DB::table('inventories')
->whereNull('deleted_at')
->whereNotNull('expiry_date')
->where('expiry_date', '<=', $expiryThreshold)
->distinct()
->count(DB::raw('CONCAT(warehouse_id, "-", product_id)'));
// 分頁
$paginated = $query->paginate($perPage)->withQueryString();
// 為每筆紀錄附加最後入庫/出庫時間 + 狀態
$items = collect($paginated->items())->map(function ($item) use ($today, $expiryThreshold, $isGrouped) {
// 如果是聚合模式Transaction 查詢也需要調整或略過單一批次查詢
$lastIn = null;
$lastOut = null;
if (!$isGrouped) {
$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,
'is_grouped' => $isGrouped,
];
});
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. 庫存品項數 (Unique Product-Warehouse)
$totalItems = DB::table('inventories')
->whereNull('deleted_at')
->distinct()
->count(DB::raw('CONCAT(warehouse_id, "-", product_id)'));
// 2. 低庫存 (品項計數)
$lowStockCount = DB::table('warehouse_product_safety_stocks as ss')
->join(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'),
function ($join) {
$join->on('ss.warehouse_id', '=', 'inv.warehouse_id')
->on('ss.product_id', '=', 'inv.product_id');
})
->whereRaw('inv.total_qty <= ss.safety_stock')
->count();
// 3. 負庫存 (品項計數)
$negativeCount = DB::table(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'))
->where('total_qty', '<', 0)
->count();
// 4. 即將過期 (品項計數)
$expiringCount = DB::table('inventories')
->whereNull('deleted_at')
->whereNotNull('expiry_date')
->where('expiry_date', '<=', $expiryThreshold)
->distinct()
->count(DB::raw('CONCAT(warehouse_id, "-", product_id)'));
// 異常庫存前 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'),
'abnormalItems' => $abnormalItems,
];
}
}