Files
star-erp/app/Modules/Procurement/Services/ProcurementAnalysisService.php
sky121113 58bd995cd8
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m13s
ERP-Deploy-Production / deploy-production (push) Successful in 1m13s
feat: 新增採購統計分析功能並優化 API 文件顯示樣式
- 在 RoleController 中新增 procurement_analysis 權限群組名稱
- 在 Procurement 模組中新增採購統計分析路由
- 在 PermissionSeeder 中新增 procurement_analysis.view 權限並分配給角色
- 在側邊欄「報表與分析」分組中新增「採購統計分析」項目
- 優化 API 文件視圖中的表格外觀樣式
2026-03-03 11:38:04 +08:00

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,
];
}
}