feat: 新增採購統計分析功能並優化 API 文件顯示樣式
- 在 RoleController 中新增 procurement_analysis 權限群組名稱 - 在 Procurement 模組中新增採購統計分析路由 - 在 PermissionSeeder 中新增 procurement_analysis.view 權限並分配給角色 - 在側邊欄「報表與分析」分組中新增「採購統計分析」項目 - 優化 API 文件視圖中的表格外觀樣式
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Procurement\Services\ProcurementAnalysisService;
|
||||
use App\Modules\Procurement\Models\Vendor;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ProcurementAnalysisController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ProcurementAnalysisService $analysisService,
|
||||
protected InventoryServiceInterface $inventoryService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$filters = $request->only([
|
||||
'date_from', 'date_to', 'vendor_id', 'warehouse_id',
|
||||
]);
|
||||
|
||||
// 取得各面向數據
|
||||
$kpis = $this->analysisService->getKPIs($filters);
|
||||
$deliveryAnalysis = $this->analysisService->getDeliveryAnalysis($filters);
|
||||
$quantityAnalysis = $this->analysisService->getQuantityAnalysis($filters);
|
||||
$priceTrendAnalysis = $this->analysisService->getPriceTrendAnalysis($filters);
|
||||
|
||||
// 取得篩選器選項(跨模組透過 Service 取得倉庫)
|
||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||
$vendors = Vendor::select('id', 'name', 'code')->orderBy('name')->get();
|
||||
|
||||
return Inertia::render('Procurement/Analysis/Index', [
|
||||
'kpis' => $kpis,
|
||||
'deliveryAnalysis' => $deliveryAnalysis,
|
||||
'quantityAnalysis' => $quantityAnalysis,
|
||||
'priceTrendAnalysis' => $priceTrendAnalysis,
|
||||
'vendors' => $vendors,
|
||||
'warehouses' => $warehouses,
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Procurement\Controllers\VendorController;
|
||||
use App\Modules\Procurement\Controllers\VendorProductController;
|
||||
use App\Modules\Procurement\Controllers\PurchaseOrderController;
|
||||
use App\Modules\Procurement\Controllers\ProcurementAnalysisController;
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
// 廠商管理
|
||||
@@ -77,4 +78,9 @@ Route::middleware('auth')->group(function () {
|
||||
|
||||
Route::delete('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'destroy'])->middleware('permission:delivery_notes.delete')->name('delivery-notes.destroy');
|
||||
});
|
||||
|
||||
// 採購統計分析
|
||||
Route::get('/procurement/analysis', [ProcurementAnalysisController::class, 'index'])
|
||||
->middleware('permission:procurement_analysis.view')
|
||||
->name('procurement.analysis.index');
|
||||
});
|
||||
|
||||
280
app/Modules/Procurement/Services/ProcurementAnalysisService.php
Normal file
280
app/Modules/Procurement/Services/ProcurementAnalysisService.php
Normal file
@@ -0,0 +1,280 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user