feat: 新增採購統計分析功能並優化 API 文件顯示樣式
- 在 RoleController 中新增 procurement_analysis 權限群組名稱 - 在 Procurement 模組中新增採購統計分析路由 - 在 PermissionSeeder 中新增 procurement_analysis.view 權限並分配給角色 - 在側邊欄「報表與分析」分組中新增「採購統計分析」項目 - 優化 API 文件視圖中的表格外觀樣式
This commit is contained in:
@@ -199,6 +199,7 @@ class RoleController extends Controller
|
|||||||
'sales_imports' => '銷售單匯入管理',
|
'sales_imports' => '銷售單匯入管理',
|
||||||
'sales_orders' => '銷售訂單管理',
|
'sales_orders' => '銷售訂單管理',
|
||||||
'store_requisitions' => '門市叫貨申請',
|
'store_requisitions' => '門市叫貨申請',
|
||||||
|
'procurement_analysis' => '採購統計分析',
|
||||||
'users' => '使用者管理',
|
'users' => '使用者管理',
|
||||||
'roles' => '角色與權限',
|
'roles' => '角色與權限',
|
||||||
'system' => '系統管理',
|
'system' => '系統管理',
|
||||||
|
|||||||
@@ -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\VendorController;
|
||||||
use App\Modules\Procurement\Controllers\VendorProductController;
|
use App\Modules\Procurement\Controllers\VendorProductController;
|
||||||
use App\Modules\Procurement\Controllers\PurchaseOrderController;
|
use App\Modules\Procurement\Controllers\PurchaseOrderController;
|
||||||
|
use App\Modules\Procurement\Controllers\ProcurementAnalysisController;
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
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::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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,6 +162,9 @@ class PermissionSeeder extends Seeder
|
|||||||
|
|
||||||
// 銷售訂單管理 (API)
|
// 銷售訂單管理 (API)
|
||||||
'sales_orders.view' => '檢視',
|
'sales_orders.view' => '檢視',
|
||||||
|
|
||||||
|
// 採購統計分析
|
||||||
|
'procurement_analysis.view' => '檢視',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($permissions as $name => $displayName) {
|
foreach ($permissions as $name => $displayName) {
|
||||||
@@ -210,6 +213,7 @@ class PermissionSeeder extends Seeder
|
|||||||
'sales_imports.view', 'sales_imports.create', 'sales_imports.confirm', 'sales_imports.delete',
|
'sales_imports.view', 'sales_imports.create', 'sales_imports.confirm', 'sales_imports.delete',
|
||||||
'store_requisitions.view', 'store_requisitions.create', 'store_requisitions.edit',
|
'store_requisitions.view', 'store_requisitions.create', 'store_requisitions.edit',
|
||||||
'store_requisitions.delete', 'store_requisitions.approve', 'store_requisitions.cancel',
|
'store_requisitions.delete', 'store_requisitions.approve', 'store_requisitions.cancel',
|
||||||
|
'procurement_analysis.view',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// warehouse-manager 管理庫存與倉庫
|
// warehouse-manager 管理庫存與倉庫
|
||||||
@@ -236,6 +240,7 @@ class PermissionSeeder extends Seeder
|
|||||||
'vendors.view', 'vendors.create', 'vendors.edit',
|
'vendors.view', 'vendors.create', 'vendors.edit',
|
||||||
'inventory.view',
|
'inventory.view',
|
||||||
'goods_receipts.view', 'goods_receipts.create',
|
'goods_receipts.view', 'goods_receipts.create',
|
||||||
|
'procurement_analysis.view',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// viewer 僅能查看
|
// viewer 僅能查看
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export default function AuthenticatedLayout({
|
|||||||
id: "report-management",
|
id: "report-management",
|
||||||
label: "報表與分析",
|
label: "報表與分析",
|
||||||
icon: <BarChart3 className="h-5 w-5" />,
|
icon: <BarChart3 className="h-5 w-5" />,
|
||||||
permission: ["accounting.view", "inventory_report.view"],
|
permission: ["accounting.view", "inventory_report.view", "procurement_analysis.view"],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: "accounting-report",
|
id: "accounting-report",
|
||||||
@@ -278,6 +278,13 @@ export default function AuthenticatedLayout({
|
|||||||
route: "/inventory/analysis",
|
route: "/inventory/analysis",
|
||||||
permission: "inventory_report.view",
|
permission: "inventory_report.view",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "procurement-analysis",
|
||||||
|
label: "採購統計分析",
|
||||||
|
icon: <ShoppingCart className="h-4 w-4" />,
|
||||||
|
route: "/procurement/analysis",
|
||||||
|
permission: "procurement_analysis.view",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "inventory-traceability",
|
id: "inventory-traceability",
|
||||||
label: "批號溯源",
|
label: "批號溯源",
|
||||||
|
|||||||
848
resources/js/Pages/Procurement/Analysis/Index.tsx
Normal file
848
resources/js/Pages/Procurement/Analysis/Index.tsx
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { Head, router } from "@inertiajs/react";
|
||||||
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Label } from "@/Components/ui/label";
|
||||||
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
TrendingUp,
|
||||||
|
DollarSign,
|
||||||
|
Truck,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
Package,
|
||||||
|
Filter,
|
||||||
|
RotateCcw,
|
||||||
|
ShoppingCart,
|
||||||
|
Info,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/Components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
// ===== 型別定義 =====
|
||||||
|
interface KPIs {
|
||||||
|
total_amount: number;
|
||||||
|
total_orders: number;
|
||||||
|
total_receipts: number;
|
||||||
|
avg_delivery_days: number;
|
||||||
|
on_time_rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VendorDelivery {
|
||||||
|
vendor_id: number;
|
||||||
|
vendor_name: string;
|
||||||
|
total_count: number;
|
||||||
|
avg_days: number;
|
||||||
|
min_days: number;
|
||||||
|
max_days: number;
|
||||||
|
on_time_count: number;
|
||||||
|
on_time_rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DelayDistribution {
|
||||||
|
category: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthlyTrend {
|
||||||
|
month: string;
|
||||||
|
total_quantity: number;
|
||||||
|
total_amount: number;
|
||||||
|
receipt_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VendorShare {
|
||||||
|
vendor_id: number;
|
||||||
|
vendor_name: string;
|
||||||
|
total_amount: number;
|
||||||
|
total_quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductRanking {
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
product_code: string;
|
||||||
|
total_quantity: number;
|
||||||
|
total_amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PriceTrendItem {
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
month: string;
|
||||||
|
avg_price: number;
|
||||||
|
min_price: number;
|
||||||
|
max_price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VendorComparison {
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
vendor_id: number;
|
||||||
|
vendor_name: string;
|
||||||
|
avg_price: number;
|
||||||
|
min_price: number;
|
||||||
|
max_price: number;
|
||||||
|
purchase_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
kpis: KPIs;
|
||||||
|
deliveryAnalysis: {
|
||||||
|
vendor_delivery: VendorDelivery[];
|
||||||
|
delay_distribution: DelayDistribution[];
|
||||||
|
};
|
||||||
|
quantityAnalysis: {
|
||||||
|
monthly_trend: MonthlyTrend[];
|
||||||
|
vendor_share: VendorShare[];
|
||||||
|
product_ranking: ProductRanking[];
|
||||||
|
};
|
||||||
|
priceTrendAnalysis: {
|
||||||
|
price_trend: PriceTrendItem[];
|
||||||
|
vendor_comparison: VendorComparison[];
|
||||||
|
};
|
||||||
|
vendors: FilterOption[];
|
||||||
|
warehouses: FilterOption[];
|
||||||
|
filters: {
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
vendor_id?: string;
|
||||||
|
warehouse_id?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 圓餅圖色彩
|
||||||
|
const PIE_COLORS = [
|
||||||
|
"#0ea5e9", "#8b5cf6", "#10b981", "#f59e0b", "#ef4444",
|
||||||
|
"#ec4899", "#06b6d4", "#84cc16", "#f97316", "#6366f1",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tab 定義
|
||||||
|
const TABS = [
|
||||||
|
{ key: "delivery", label: "供貨時效", icon: <Truck className="h-4 w-4" /> },
|
||||||
|
{ key: "quantity", label: "數量分析", icon: <Package className="h-4 w-4" /> },
|
||||||
|
{ key: "price", label: "價格趨勢", icon: <TrendingUp className="h-4 w-4" /> },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TabKey = (typeof TABS)[number]["key"];
|
||||||
|
|
||||||
|
export default function ProcurementAnalysisIndex({
|
||||||
|
kpis,
|
||||||
|
deliveryAnalysis,
|
||||||
|
quantityAnalysis,
|
||||||
|
priceTrendAnalysis,
|
||||||
|
vendors,
|
||||||
|
warehouses,
|
||||||
|
filters,
|
||||||
|
}: Props) {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>("delivery");
|
||||||
|
const [localFilters, setLocalFilters] = useState({
|
||||||
|
date_from: filters.date_from || "",
|
||||||
|
date_to: filters.date_to || "",
|
||||||
|
vendor_id: filters.vendor_id || "",
|
||||||
|
warehouse_id: filters.warehouse_id || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleApplyFilters = () => {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
Object.entries(localFilters).forEach(([key, value]) => {
|
||||||
|
if (value) params[key] = value;
|
||||||
|
});
|
||||||
|
router.get(route("procurement.analysis.index"), params, {
|
||||||
|
preserveState: true,
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
setLocalFilters({ date_from: "", date_to: "", vendor_id: "", warehouse_id: "" });
|
||||||
|
router.get(route("procurement.analysis.index"), {}, {
|
||||||
|
preserveState: true,
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== KPI 卡片資料 =====
|
||||||
|
const kpiCards = [
|
||||||
|
{
|
||||||
|
label: "採購總金額",
|
||||||
|
tooltip: "期間內所有非草稿、非取消的採購單總金額",
|
||||||
|
value: `NT$ ${Math.round(kpis.total_amount).toLocaleString()}`,
|
||||||
|
description: `共 ${kpis.total_orders} 筆採購單`,
|
||||||
|
icon: <DollarSign className="h-5 w-5" />,
|
||||||
|
color: "text-blue-600",
|
||||||
|
bgColor: "bg-blue-50",
|
||||||
|
borderColor: "border-blue-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "進貨筆數",
|
||||||
|
tooltip: "期間內狀態為「已完成」的進貨單數量",
|
||||||
|
value: kpis.total_receipts,
|
||||||
|
description: "已完成進貨單",
|
||||||
|
icon: <Package className="h-5 w-5" />,
|
||||||
|
color: "text-emerald-600",
|
||||||
|
bgColor: "bg-emerald-50",
|
||||||
|
borderColor: "border-emerald-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "平均交期",
|
||||||
|
tooltip: "真實進貨日期減去採購單日期的平均天數",
|
||||||
|
value: `${kpis.avg_delivery_days} 天`,
|
||||||
|
description: "下單到到貨平均天數",
|
||||||
|
icon: <Clock className="h-5 w-5" />,
|
||||||
|
color: "text-amber-600",
|
||||||
|
bgColor: "bg-amber-50",
|
||||||
|
borderColor: "border-amber-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "準時到貨率",
|
||||||
|
tooltip: "進貨單真實進貨日期早於或等於採購單預計到貨日的比例",
|
||||||
|
value: `${kpis.on_time_rate}%`,
|
||||||
|
description: "於預計交期內到貨",
|
||||||
|
icon: <CheckCircle className="h-5 w-5" />,
|
||||||
|
color: kpis.on_time_rate >= 80 ? "text-emerald-600" : "text-red-600",
|
||||||
|
bgColor: kpis.on_time_rate >= 80 ? "bg-emerald-50" : "bg-red-50",
|
||||||
|
borderColor: kpis.on_time_rate >= 80 ? "border-emerald-100" : "border-red-100",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "報表與分析", href: "#" },
|
||||||
|
{ label: "採購統計分析", href: route("procurement.analysis.index"), isPage: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title="採購統計分析" />
|
||||||
|
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl space-y-6">
|
||||||
|
{/* 頁面標題 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-6 w-6 text-primary-main" />
|
||||||
|
採購統計分析
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
分析廠商供貨時效、進貨數量、單價趨勢,支援採購決策與成本控制
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 篩選列 */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-grey-4 p-5">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
|
||||||
|
<div className="md:col-span-2 space-y-1">
|
||||||
|
<Label className="text-xs text-grey-2 font-medium">起始日期</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={localFilters.date_from}
|
||||||
|
onChange={(e) => setLocalFilters((f) => ({ ...f, date_from: e.target.value }))}
|
||||||
|
className="h-9 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 space-y-1">
|
||||||
|
<Label className="text-xs text-grey-2 font-medium">結束日期</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={localFilters.date_to}
|
||||||
|
onChange={(e) => setLocalFilters((f) => ({ ...f, date_to: e.target.value }))}
|
||||||
|
className="h-9 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-3 space-y-1">
|
||||||
|
<Label className="text-xs text-grey-2 font-medium">廠商</Label>
|
||||||
|
<SearchableSelect
|
||||||
|
value={localFilters.vendor_id || "all"}
|
||||||
|
onValueChange={(v) => setLocalFilters((f) => ({ ...f, vendor_id: v === "all" ? "" : v }))}
|
||||||
|
options={[{ label: "全部廠商", value: "all" }, ...vendors.map((v) => ({ label: v.name, value: v.id.toString() }))]}
|
||||||
|
className="w-full h-9"
|
||||||
|
placeholder="選擇廠商..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 space-y-1">
|
||||||
|
<Label className="text-xs text-grey-2 font-medium">倉庫</Label>
|
||||||
|
<SearchableSelect
|
||||||
|
value={localFilters.warehouse_id || "all"}
|
||||||
|
onValueChange={(v) => setLocalFilters((f) => ({ ...f, warehouse_id: v === "all" ? "" : v }))}
|
||||||
|
options={[{ label: "全部倉庫", value: "all" }, ...warehouses.map((w) => ({ label: w.name, value: w.id.toString() }))]}
|
||||||
|
className="w-full h-9"
|
||||||
|
placeholder="選擇倉庫..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-3 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
className="flex-1 items-center gap-2 button-outlined-primary h-9"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleApplyFilters}
|
||||||
|
className="flex-1 button-filled-primary h-9 gap-2"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" /> 查詢
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI 卡片列 */}
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{kpiCards.map((card) => (
|
||||||
|
<div key={card.label} className={`rounded-xl border ${card.borderColor} bg-white p-5 shadow-sm`}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className={`p-2 rounded-lg ${card.bgColor} ${card.color}`}>
|
||||||
|
{card.icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-500 mb-1 flex items-center gap-1">
|
||||||
|
{card.label}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Info className="h-3 w-3 text-gray-400 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[200px] text-xs">
|
||||||
|
<p>{card.tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 mb-1">{card.value}</div>
|
||||||
|
<div className="text-xs text-gray-400">{card.description}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{/* Tab 切換 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium border-b-2 transition-colors ${activeTab === tab.key
|
||||||
|
? "border-primary-main text-primary-main bg-primary-lightest"
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{activeTab === "delivery" && (
|
||||||
|
<DeliveryTab
|
||||||
|
vendorDelivery={deliveryAnalysis.vendor_delivery}
|
||||||
|
delayDistribution={deliveryAnalysis.delay_distribution}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === "quantity" && (
|
||||||
|
<QuantityTab
|
||||||
|
monthlyTrend={quantityAnalysis.monthly_trend}
|
||||||
|
vendorShare={quantityAnalysis.vendor_share}
|
||||||
|
productRanking={quantityAnalysis.product_ranking}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === "price" && (
|
||||||
|
<PriceTab
|
||||||
|
priceTrend={priceTrendAnalysis.price_trend}
|
||||||
|
vendorComparison={priceTrendAnalysis.vendor_comparison}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Tab 1: 供貨時效 =====
|
||||||
|
function DeliveryTab({
|
||||||
|
vendorDelivery,
|
||||||
|
delayDistribution,
|
||||||
|
}: {
|
||||||
|
vendorDelivery: VendorDelivery[];
|
||||||
|
delayDistribution: DelayDistribution[];
|
||||||
|
}) {
|
||||||
|
if (vendorDelivery.length === 0) {
|
||||||
|
return <EmptyState message="尚無供貨時效資料,請確認篩選條件或是否有已完成的進貨單。" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDelayDistribution = useMemo(() => {
|
||||||
|
return delayDistribution.map(d => ({ ...d, count: Number(d.count) }));
|
||||||
|
}, [delayDistribution]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* 廠商平均交期長條圖 */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<Truck className="h-4 w-4 text-blue-500" />
|
||||||
|
廠商平均交期天數
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Info className="h-3.5 w-3.5 text-gray-400 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[250px] text-xs">
|
||||||
|
<p>將進貨資料依廠商分組,計算「進貨日 - 採購日」的平均天數、極端值與準時率。預設由快到慢排序。</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</h3>
|
||||||
|
<div className="h-[350px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={vendorDelivery} layout="vertical" margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||||
|
<XAxis type="number" unit=" 天" />
|
||||||
|
<YAxis dataKey="vendor_name" type="category" width={120} tick={{ fontSize: 12 }} />
|
||||||
|
<RechartsTooltip
|
||||||
|
formatter={(value: number, name: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
avg_days: "平均交期",
|
||||||
|
min_days: "最短",
|
||||||
|
max_days: "最長",
|
||||||
|
};
|
||||||
|
return [`${value} 天`, labels[name] || name];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend formatter={(v) => ({ avg_days: "平均", min_days: "最短", max_days: "最長" }[v] || v)} />
|
||||||
|
<Bar dataKey="avg_days" fill="#3b82f6" radius={[0, 4, 4, 0]} />
|
||||||
|
<Bar dataKey="min_days" fill="#10b981" radius={[0, 4, 4, 0]} />
|
||||||
|
<Bar dataKey="max_days" fill="#f59e0b" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 交期分佈圓餅圖 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-amber-500" />
|
||||||
|
交期分佈
|
||||||
|
</h3>
|
||||||
|
<div className="h-[350px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={parsedDelayDistribution}
|
||||||
|
dataKey="count"
|
||||||
|
nameKey="category"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={100}
|
||||||
|
label={false}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{parsedDelayDistribution.map((_, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<RechartsTooltip
|
||||||
|
formatter={(value: number) => {
|
||||||
|
const total = parsedDelayDistribution.reduce((sum, item) => sum + item.count, 0);
|
||||||
|
const percent = total > 0 ? ((value / total) * 100).toFixed(0) : 0;
|
||||||
|
return [`${value} 筆 (${percent}%)`, "數量"];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 廠商準時率排行表格 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-4 w-4 text-emerald-500" />
|
||||||
|
廠商準時到貨排行
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Info className="h-3.5 w-3.5 text-gray-400 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[250px] text-xs">
|
||||||
|
<p>準時率 = (該廠商準時進貨筆數 / 總進貨筆數) * 100%</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</h3>
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">排名</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">廠商</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">進貨次數</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">平均交期</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">最短</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">最長</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">準時率</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{vendorDelivery.map((v, idx) => (
|
||||||
|
<tr key={v.vendor_id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-gray-900">{idx + 1}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900">{v.vendor_name}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-center text-gray-600">{v.total_count}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-center font-medium text-blue-600">{v.avg_days} 天</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-center text-emerald-600">{v.min_days} 天</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-center text-amber-600">{v.max_days} 天</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${v.on_time_rate >= 80 ? "bg-emerald-100 text-emerald-800" :
|
||||||
|
v.on_time_rate >= 60 ? "bg-amber-100 text-amber-800" :
|
||||||
|
"bg-red-100 text-red-800"
|
||||||
|
}`}>
|
||||||
|
{v.on_time_rate}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Tab 2: 數量分析 =====
|
||||||
|
function QuantityTab({
|
||||||
|
monthlyTrend,
|
||||||
|
vendorShare,
|
||||||
|
productRanking,
|
||||||
|
}: {
|
||||||
|
monthlyTrend: MonthlyTrend[];
|
||||||
|
vendorShare: VendorShare[];
|
||||||
|
productRanking: ProductRanking[];
|
||||||
|
}) {
|
||||||
|
if (monthlyTrend.length === 0 && vendorShare.length === 0) {
|
||||||
|
return <EmptyState message="尚無進貨數量資料,請確認篩選條件。" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedVendorShare = useMemo(() => {
|
||||||
|
return vendorShare.map(d => ({ ...d, total_amount: Number(d.total_amount) }));
|
||||||
|
}, [vendorShare]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<TooltipProvider>
|
||||||
|
{/* 月度趨勢 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-blue-500" />
|
||||||
|
月度進貨金額趨勢
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Info className="h-3.5 w-3.5 text-gray-400 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[250px] text-xs">
|
||||||
|
<p>將所有已完成的進貨單依月份分組,加總總進貨數量與金額。</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</h3>
|
||||||
|
<div className="h-[350px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={monthlyTrend} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorAmount" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} tick={{ fontSize: 12 }} />
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<RechartsTooltip
|
||||||
|
formatter={(value: number, name: string) => {
|
||||||
|
if (name === "total_amount") return [`NT$ ${value.toLocaleString()}`, "進貨金額"];
|
||||||
|
if (name === "receipt_count") return [`${value} 筆`, "進貨筆數"];
|
||||||
|
return [value, name];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="total_amount" stroke="#3b82f6" fillOpacity={1} fill="url(#colorAmount)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 廠商佔比 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<ShoppingCart className="h-4 w-4 text-purple-500" />
|
||||||
|
廠商進貨金額佔比
|
||||||
|
</h3>
|
||||||
|
<div className="h-[350px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={parsedVendorShare}
|
||||||
|
dataKey="total_amount"
|
||||||
|
nameKey="vendor_name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={100}
|
||||||
|
label={false}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{parsedVendorShare.map((_, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<RechartsTooltip
|
||||||
|
formatter={(value: number) => {
|
||||||
|
const total = parsedVendorShare.reduce((sum, item) => sum + item.total_amount, 0);
|
||||||
|
const percent = total > 0 ? ((value / total) * 100).toFixed(0) : 0;
|
||||||
|
return [`NT$ ${value.toLocaleString()} (${percent}%)`, "金額"];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend formatter={(value) => value.length > 6 ? value.slice(0, 6) + '…' : value} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 商品進貨排行 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<Package className="h-4 w-4 text-emerald-500" />
|
||||||
|
商品進貨排行 Top 10
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Info className="h-3.5 w-3.5 text-gray-400 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[250px] text-xs">
|
||||||
|
<p>依據商品分組,加總總進貨量與金額,並依金額高低反向排序出前 10 名。</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</h3>
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">排名</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">商品名稱</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">商品編號</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">進貨數量</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">進貨金額</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{productRanking.map((p, idx) => (
|
||||||
|
<tr key={p.product_id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-gray-900">{idx + 1}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900">{p.product_name}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500">{p.product_code}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right text-gray-700">{Number(p.total_quantity).toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right font-medium text-blue-600">NT$ {Number(p.total_amount).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{productRanking.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-gray-400 text-sm">無符合條件的資料</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Tab 3: 價格趨勢 =====
|
||||||
|
function PriceTab({
|
||||||
|
priceTrend,
|
||||||
|
vendorComparison,
|
||||||
|
}: {
|
||||||
|
priceTrend: PriceTrendItem[];
|
||||||
|
vendorComparison: VendorComparison[];
|
||||||
|
}) {
|
||||||
|
if (priceTrend.length === 0 && vendorComparison.length === 0) {
|
||||||
|
return <EmptyState message="尚無單價趨勢資料,請確認篩選條件。" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 將 priceTrend 轉為折線圖資料(每個月為 X 軸,每個商品為一條線)
|
||||||
|
const productNames = [...new Set(priceTrend.map((p) => p.product_name))];
|
||||||
|
const months = [...new Set(priceTrend.map((p) => p.month))].sort();
|
||||||
|
|
||||||
|
const chartData = months.map((month) => {
|
||||||
|
const row: Record<string, string | number> = { month };
|
||||||
|
productNames.forEach((name) => {
|
||||||
|
const item = priceTrend.find((p) => p.month === month && p.product_name === name);
|
||||||
|
row[name] = item ? item.avg_price : 0;
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 將 vendorComparison 按商品分組
|
||||||
|
const comparisonGrouped = vendorComparison.reduce<Record<string, VendorComparison[]>>((acc, item) => {
|
||||||
|
if (!acc[item.product_name]) acc[item.product_name] = [];
|
||||||
|
acc[item.product_name].push(item);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<TooltipProvider>
|
||||||
|
{/* 商品單價趨勢折線圖 */}
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-blue-500" />
|
||||||
|
商品月均單價趨勢(Top 10 商品)
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Info className="h-3.5 w-3.5 text-gray-400 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[250px] text-xs">
|
||||||
|
<p>先從期間內找出「進貨總金額最高的前 10 項商品」作為追蹤目標,計算它們每月的平均進貨單價繪製成折線圖。</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</h3>
|
||||||
|
<div className="h-[400px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis tickFormatter={(v) => `$${v}`} tick={{ fontSize: 12 }} />
|
||||||
|
<RechartsTooltip
|
||||||
|
formatter={(value: number, name: string) => [
|
||||||
|
`NT$ ${value.toLocaleString()}`, name
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
{productNames.slice(0, 8).map((name, idx) => (
|
||||||
|
<Line
|
||||||
|
key={name}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={name}
|
||||||
|
stroke={PIE_COLORS[idx % PIE_COLORS.length]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 跨廠商比價表格 */}
|
||||||
|
{Object.keys(comparisonGrouped).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<DollarSign className="h-4 w-4 text-amber-500" />
|
||||||
|
跨廠商比價分析
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Info className="h-3.5 w-3.5 text-gray-400 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[250px] text-xs">
|
||||||
|
<p>以進貨金額最高的前 10項商品為基準,列出各廠商過去期間的平均、最高、最低進貨單價以供比價,最低價將會標記「最優」。</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(comparisonGrouped).map(([productName, items]) => (
|
||||||
|
<div key={productName} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-4 py-2 bg-gray-50 border-b border-gray-200">
|
||||||
|
<span className="text-sm font-semibold text-gray-700">{productName}</span>
|
||||||
|
</div>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50/50">
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500">廠商</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500">平均單價</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500">最低價</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500">最高價</th>
|
||||||
|
<th className="px-4 py-2 text-center text-xs font-medium text-gray-500">交易次數</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{items.map((item, idx) => {
|
||||||
|
const isLowest = item.avg_price === Math.min(...items.map((i) => i.avg_price));
|
||||||
|
return (
|
||||||
|
<tr key={item.vendor_id} className={`hover:bg-gray-50 ${isLowest ? "bg-emerald-50/50" : ""}`}>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-900">
|
||||||
|
{item.vendor_name}
|
||||||
|
{isLowest && (
|
||||||
|
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">
|
||||||
|
最優
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={`px-4 py-2 text-sm text-right font-medium ${isLowest ? "text-emerald-600" : "text-gray-700"}`}>
|
||||||
|
NT$ {item.avg_price.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-right text-gray-600">
|
||||||
|
NT$ {item.min_price.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-right text-gray-600">
|
||||||
|
NT$ {item.max_price.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-center text-gray-600">
|
||||||
|
{item.purchase_count}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 空狀態 =====
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||||
|
<BarChart3 className="h-12 w-12 mb-4 text-gray-300" />
|
||||||
|
<p className="text-sm">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,9 +57,9 @@
|
|||||||
prose-headings:scroll-mt-20
|
prose-headings:scroll-mt-20
|
||||||
prose-h1:text-4xl prose-h1:font-extrabold prose-h1:tracking-tight
|
prose-h1:text-4xl prose-h1:font-extrabold prose-h1:tracking-tight
|
||||||
prose-pre:p-6 prose-pre:text-sm
|
prose-pre:p-6 prose-pre:text-sm
|
||||||
prose-table:border prose-table:rounded-xl prose-table:overflow-hidden
|
prose-table:border prose-table:border-slate-300 prose-table:rounded-xl prose-table:overflow-hidden
|
||||||
prose-th:bg-slate-100 prose-th:p-4
|
prose-th:bg-slate-100 prose-th:p-4 prose-th:border prose-th:border-slate-300 prose-th:text-left prose-th:font-semibold
|
||||||
prose-td:p-4 prose-td:border-t prose-td:border-slate-100">
|
prose-td:p-4 prose-td:border prose-td:border-slate-200">
|
||||||
{!! $content !!}
|
{!! $content !!}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user