- 在 RoleController 中新增 procurement_analysis 權限群組名稱 - 在 Procurement 模組中新增採購統計分析路由 - 在 PermissionSeeder 中新增 procurement_analysis.view 權限並分配給角色 - 在側邊欄「報表與分析」分組中新增「採購統計分析」項目 - 優化 API 文件視圖中的表格外觀樣式
281 lines
12 KiB
PHP
281 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Modules\Procurement\Services;
|
|
|
|
use App\Modules\Procurement\Models\PurchaseOrder;
|
|
use App\Modules\Procurement\Models\PurchaseOrderItem;
|
|
use App\Modules\Procurement\Models\Vendor;
|
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
class ProcurementAnalysisService
|
|
{
|
|
public function __construct(
|
|
protected InventoryServiceInterface $inventoryService,
|
|
) {}
|
|
|
|
/**
|
|
* 取得 KPI 總覽數據
|
|
*/
|
|
public function getKPIs(array $filters): array
|
|
{
|
|
$dateFrom = $filters['date_from'] ?? Carbon::now()->subMonths(6)->format('Y-m-d');
|
|
$dateTo = $filters['date_to'] ?? Carbon::now()->format('Y-m-d');
|
|
$vendorId = $filters['vendor_id'] ?? null;
|
|
$warehouseId = $filters['warehouse_id'] ?? null;
|
|
|
|
// 採購單基礎查詢(排除草稿與已取消)
|
|
$poQuery = PurchaseOrder::whereNotIn('status', ['draft', 'cancelled'])
|
|
->whereBetween('order_date', [$dateFrom, $dateTo]);
|
|
if ($vendorId) $poQuery->where('vendor_id', $vendorId);
|
|
if ($warehouseId) $poQuery->where('warehouse_id', $warehouseId);
|
|
|
|
$totalAmount = (clone $poQuery)->sum('grand_total') ?: (clone $poQuery)->sum('total_amount');
|
|
$totalOrders = (clone $poQuery)->count();
|
|
|
|
// 進貨單查詢
|
|
$grQuery = DB::table('goods_receipts')
|
|
->where('status', 'completed')
|
|
->whereBetween('received_date', [$dateFrom, $dateTo])
|
|
->whereNull('deleted_at');
|
|
if ($vendorId) $grQuery->where('vendor_id', $vendorId);
|
|
if ($warehouseId) $grQuery->where('warehouse_id', $warehouseId);
|
|
|
|
$totalReceipts = (clone $grQuery)->count();
|
|
|
|
// 平均交期天數(採購單→進貨單)
|
|
$deliveryStats = DB::table('purchase_orders as po')
|
|
->join('goods_receipts as gr', 'gr.purchase_order_id', '=', 'po.id')
|
|
->whereNotIn('po.status', ['draft', 'cancelled'])
|
|
->where('gr.status', 'completed')
|
|
->whereNull('gr.deleted_at')
|
|
->whereBetween('po.order_date', [$dateFrom, $dateTo]);
|
|
if ($vendorId) $deliveryStats->where('po.vendor_id', $vendorId);
|
|
if ($warehouseId) $deliveryStats->where('po.warehouse_id', $warehouseId);
|
|
|
|
$deliveryStats = $deliveryStats->selectRaw('
|
|
AVG(DATEDIFF(gr.received_date, po.order_date)) as avg_days,
|
|
COUNT(*) as total_linked,
|
|
SUM(CASE WHEN gr.received_date <= po.expected_delivery_date THEN 1 ELSE 0 END) as on_time_count
|
|
')->first();
|
|
|
|
$avgDays = round($deliveryStats->avg_days ?? 0, 1);
|
|
$onTimeRate = $deliveryStats->total_linked > 0
|
|
? round(($deliveryStats->on_time_count / $deliveryStats->total_linked) * 100, 1)
|
|
: 0;
|
|
|
|
return [
|
|
'total_amount' => (float) $totalAmount,
|
|
'total_orders' => $totalOrders,
|
|
'total_receipts' => $totalReceipts,
|
|
'avg_delivery_days' => $avgDays,
|
|
'on_time_rate' => $onTimeRate,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 廠商供貨時效分析
|
|
*/
|
|
public function getDeliveryAnalysis(array $filters): array
|
|
{
|
|
$dateFrom = $filters['date_from'] ?? Carbon::now()->subMonths(6)->format('Y-m-d');
|
|
$dateTo = $filters['date_to'] ?? Carbon::now()->format('Y-m-d');
|
|
$vendorId = $filters['vendor_id'] ?? null;
|
|
$warehouseId = $filters['warehouse_id'] ?? null;
|
|
|
|
$query = DB::table('purchase_orders as po')
|
|
->join('goods_receipts as gr', 'gr.purchase_order_id', '=', 'po.id')
|
|
->whereNotIn('po.status', ['draft', 'cancelled'])
|
|
->where('gr.status', 'completed')
|
|
->whereNull('gr.deleted_at')
|
|
->whereBetween('po.order_date', [$dateFrom, $dateTo]);
|
|
if ($vendorId) $query->where('po.vendor_id', $vendorId);
|
|
if ($warehouseId) $query->where('po.warehouse_id', $warehouseId);
|
|
|
|
// 按廠商分組的交期統計
|
|
$vendorDelivery = (clone $query)
|
|
->join('vendors', 'vendors.id', '=', 'po.vendor_id')
|
|
->groupBy('po.vendor_id', 'vendors.name')
|
|
->selectRaw('
|
|
po.vendor_id,
|
|
vendors.name as vendor_name,
|
|
COUNT(*) as total_count,
|
|
ROUND(AVG(DATEDIFF(gr.received_date, po.order_date)), 1) as avg_days,
|
|
MIN(DATEDIFF(gr.received_date, po.order_date)) as min_days,
|
|
MAX(DATEDIFF(gr.received_date, po.order_date)) as max_days,
|
|
SUM(CASE WHEN gr.received_date <= po.expected_delivery_date THEN 1 ELSE 0 END) as on_time_count
|
|
')
|
|
->orderBy('avg_days', 'asc')
|
|
->get()
|
|
->map(function ($row) {
|
|
$row->on_time_rate = $row->total_count > 0
|
|
? round(($row->on_time_count / $row->total_count) * 100, 1) : 0;
|
|
return $row;
|
|
});
|
|
|
|
// 延遲分佈統計
|
|
$delayDistribution = (clone $query)
|
|
->selectRaw("
|
|
CASE
|
|
WHEN DATEDIFF(gr.received_date, po.order_date) <= 0 THEN '提前到貨'
|
|
WHEN DATEDIFF(gr.received_date, po.order_date) BETWEEN 1 AND 3 THEN '1-3天'
|
|
WHEN DATEDIFF(gr.received_date, po.order_date) BETWEEN 4 AND 7 THEN '4-7天'
|
|
WHEN DATEDIFF(gr.received_date, po.order_date) BETWEEN 8 AND 14 THEN '8-14天'
|
|
ELSE '超過14天'
|
|
END as category,
|
|
COUNT(*) as count
|
|
")
|
|
->groupByRaw("
|
|
CASE
|
|
WHEN DATEDIFF(gr.received_date, po.order_date) <= 0 THEN '提前到貨'
|
|
WHEN DATEDIFF(gr.received_date, po.order_date) BETWEEN 1 AND 3 THEN '1-3天'
|
|
WHEN DATEDIFF(gr.received_date, po.order_date) BETWEEN 4 AND 7 THEN '4-7天'
|
|
WHEN DATEDIFF(gr.received_date, po.order_date) BETWEEN 8 AND 14 THEN '8-14天'
|
|
ELSE '超過14天'
|
|
END
|
|
")
|
|
->get();
|
|
|
|
return [
|
|
'vendor_delivery' => $vendorDelivery,
|
|
'delay_distribution' => $delayDistribution,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 進貨數量分析
|
|
*/
|
|
public function getQuantityAnalysis(array $filters): array
|
|
{
|
|
$dateFrom = $filters['date_from'] ?? Carbon::now()->subMonths(6)->format('Y-m-d');
|
|
$dateTo = $filters['date_to'] ?? Carbon::now()->format('Y-m-d');
|
|
$vendorId = $filters['vendor_id'] ?? null;
|
|
$warehouseId = $filters['warehouse_id'] ?? null;
|
|
|
|
$baseQuery = DB::table('goods_receipts as gr')
|
|
->join('goods_receipt_items as gri', 'gri.goods_receipt_id', '=', 'gr.id')
|
|
->where('gr.status', 'completed')
|
|
->whereNull('gr.deleted_at')
|
|
->whereBetween('gr.received_date', [$dateFrom, $dateTo]);
|
|
if ($vendorId) $baseQuery->where('gr.vendor_id', $vendorId);
|
|
if ($warehouseId) $baseQuery->where('gr.warehouse_id', $warehouseId);
|
|
|
|
// 月度進貨量趨勢
|
|
$monthlyTrend = (clone $baseQuery)
|
|
->selectRaw("
|
|
DATE_FORMAT(gr.received_date, '%Y-%m') as month,
|
|
ROUND(SUM(gri.quantity_received), 2) as total_quantity,
|
|
ROUND(SUM(gri.total_amount), 2) as total_amount,
|
|
COUNT(DISTINCT gr.id) as receipt_count
|
|
")
|
|
->groupByRaw("DATE_FORMAT(gr.received_date, '%Y-%m')")
|
|
->orderBy('month', 'asc')
|
|
->get();
|
|
|
|
// 廠商佔比(按進貨金額)
|
|
$vendorShare = (clone $baseQuery)
|
|
->join('vendors', 'vendors.id', '=', 'gr.vendor_id')
|
|
->selectRaw('
|
|
gr.vendor_id,
|
|
vendors.name as vendor_name,
|
|
ROUND(SUM(gri.total_amount), 2) as total_amount,
|
|
ROUND(SUM(gri.quantity_received), 2) as total_quantity
|
|
')
|
|
->groupBy('gr.vendor_id', 'vendors.name')
|
|
->orderByDesc('total_amount')
|
|
->limit(10)
|
|
->get();
|
|
|
|
// 商品進貨排行 Top 10
|
|
$productRanking = (clone $baseQuery)
|
|
->join('products', 'products.id', '=', 'gri.product_id')
|
|
->selectRaw('
|
|
gri.product_id,
|
|
products.name as product_name,
|
|
products.code as product_code,
|
|
ROUND(SUM(gri.quantity_received), 2) as total_quantity,
|
|
ROUND(SUM(gri.total_amount), 2) as total_amount
|
|
')
|
|
->groupBy('gri.product_id', 'products.name', 'products.code')
|
|
->orderByDesc('total_amount')
|
|
->limit(10)
|
|
->get();
|
|
|
|
return [
|
|
'monthly_trend' => $monthlyTrend,
|
|
'vendor_share' => $vendorShare,
|
|
'product_ranking' => $productRanking,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 單價趨勢分析
|
|
*/
|
|
public function getPriceTrendAnalysis(array $filters): array
|
|
{
|
|
$dateFrom = $filters['date_from'] ?? Carbon::now()->subMonths(6)->format('Y-m-d');
|
|
$dateTo = $filters['date_to'] ?? Carbon::now()->format('Y-m-d');
|
|
$vendorId = $filters['vendor_id'] ?? null;
|
|
$warehouseId = $filters['warehouse_id'] ?? null;
|
|
|
|
$baseQuery = DB::table('goods_receipts as gr')
|
|
->join('goods_receipt_items as gri', 'gri.goods_receipt_id', '=', 'gr.id')
|
|
->join('products', 'products.id', '=', 'gri.product_id')
|
|
->where('gr.status', 'completed')
|
|
->whereNull('gr.deleted_at')
|
|
->whereBetween('gr.received_date', [$dateFrom, $dateTo]);
|
|
if ($vendorId) $baseQuery->where('gr.vendor_id', $vendorId);
|
|
if ($warehouseId) $baseQuery->where('gr.warehouse_id', $warehouseId);
|
|
|
|
// 商品月平均單價趨勢(取進貨金額 Top 10 的商品)
|
|
$topProductIds = (clone $baseQuery)
|
|
->selectRaw('gri.product_id, SUM(gri.total_amount) as total')
|
|
->groupBy('gri.product_id')
|
|
->orderByDesc('total')
|
|
->limit(10)
|
|
->pluck('product_id');
|
|
|
|
$priceTrend = [];
|
|
if ($topProductIds->isNotEmpty()) {
|
|
$priceTrend = (clone $baseQuery)
|
|
->whereIn('gri.product_id', $topProductIds->toArray())
|
|
->selectRaw("
|
|
gri.product_id,
|
|
products.name as product_name,
|
|
DATE_FORMAT(gr.received_date, '%Y-%m') as month,
|
|
ROUND(AVG(gri.unit_price), 2) as avg_price,
|
|
ROUND(MIN(gri.unit_price), 2) as min_price,
|
|
ROUND(MAX(gri.unit_price), 2) as max_price
|
|
")
|
|
->groupBy('gri.product_id', 'products.name', DB::raw("DATE_FORMAT(gr.received_date, '%Y-%m')"))
|
|
->orderBy('month', 'asc')
|
|
->get();
|
|
}
|
|
|
|
// 跨廠商比價(同一商品不同廠商的最近價格)
|
|
$vendorComparison = (clone $baseQuery)
|
|
->join('vendors', 'vendors.id', '=', 'gr.vendor_id')
|
|
->whereIn('gri.product_id', $topProductIds->toArray())
|
|
->selectRaw('
|
|
gri.product_id,
|
|
products.name as product_name,
|
|
gr.vendor_id,
|
|
vendors.name as vendor_name,
|
|
ROUND(AVG(gri.unit_price), 2) as avg_price,
|
|
ROUND(MIN(gri.unit_price), 2) as min_price,
|
|
ROUND(MAX(gri.unit_price), 2) as max_price,
|
|
COUNT(*) as purchase_count
|
|
')
|
|
->groupBy('gri.product_id', 'products.name', 'gr.vendor_id', 'vendors.name')
|
|
->orderBy('products.name')
|
|
->orderBy('avg_price')
|
|
->get();
|
|
|
|
return [
|
|
'price_trend' => $priceTrend,
|
|
'vendor_comparison' => $vendorComparison,
|
|
];
|
|
}
|
|
}
|