feat: 新增採購統計分析功能並優化 API 文件顯示樣式
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m13s
ERP-Deploy-Production / deploy-production (push) Successful in 1m13s

- 在 RoleController 中新增 procurement_analysis 權限群組名稱
- 在 Procurement 模組中新增採購統計分析路由
- 在 PermissionSeeder 中新增 procurement_analysis.view 權限並分配給角色
- 在側邊欄「報表與分析」分組中新增「採購統計分析」項目
- 優化 API 文件視圖中的表格外觀樣式
This commit is contained in:
2026-03-03 11:38:04 +08:00
parent 036f4a4fb6
commit 58bd995cd8
8 changed files with 1196 additions and 4 deletions

View File

@@ -199,6 +199,7 @@ class RoleController extends Controller
'sales_imports' => '銷售單匯入管理',
'sales_orders' => '銷售訂單管理',
'store_requisitions' => '門市叫貨申請',
'procurement_analysis' => '採購統計分析',
'users' => '使用者管理',
'roles' => '角色與權限',
'system' => '系統管理',

View File

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

View File

@@ -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');
});

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