diff --git a/app/Modules/Core/Controllers/RoleController.php b/app/Modules/Core/Controllers/RoleController.php index fb12b97..7d85be4 100644 --- a/app/Modules/Core/Controllers/RoleController.php +++ b/app/Modules/Core/Controllers/RoleController.php @@ -199,6 +199,7 @@ class RoleController extends Controller 'sales_imports' => '銷售單匯入管理', 'sales_orders' => '銷售訂單管理', 'store_requisitions' => '門市叫貨申請', + 'procurement_analysis' => '採購統計分析', 'users' => '使用者管理', 'roles' => '角色與權限', 'system' => '系統管理', diff --git a/app/Modules/Procurement/Controllers/ProcurementAnalysisController.php b/app/Modules/Procurement/Controllers/ProcurementAnalysisController.php new file mode 100644 index 0000000..c28489e --- /dev/null +++ b/app/Modules/Procurement/Controllers/ProcurementAnalysisController.php @@ -0,0 +1,45 @@ +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, + ]); + } +} diff --git a/app/Modules/Procurement/Routes/web.php b/app/Modules/Procurement/Routes/web.php index 73c7382..f280093 100644 --- a/app/Modules/Procurement/Routes/web.php +++ b/app/Modules/Procurement/Routes/web.php @@ -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'); }); diff --git a/app/Modules/Procurement/Services/ProcurementAnalysisService.php b/app/Modules/Procurement/Services/ProcurementAnalysisService.php new file mode 100644 index 0000000..eb77151 --- /dev/null +++ b/app/Modules/Procurement/Services/ProcurementAnalysisService.php @@ -0,0 +1,280 @@ +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, + ]; + } +} diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 16276c1..e9c0b50 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -162,6 +162,9 @@ class PermissionSeeder extends Seeder // 銷售訂單管理 (API) 'sales_orders.view' => '檢視', + + // 採購統計分析 + 'procurement_analysis.view' => '檢視', ]; 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', 'store_requisitions.view', 'store_requisitions.create', 'store_requisitions.edit', 'store_requisitions.delete', 'store_requisitions.approve', 'store_requisitions.cancel', + 'procurement_analysis.view', ]); // warehouse-manager 管理庫存與倉庫 @@ -236,6 +240,7 @@ class PermissionSeeder extends Seeder 'vendors.view', 'vendors.create', 'vendors.edit', 'inventory.view', 'goods_receipts.view', 'goods_receipts.create', + 'procurement_analysis.view', ]); // viewer 僅能查看 diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index a2c7c95..a5fd2f9 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -255,7 +255,7 @@ export default function AuthenticatedLayout({ id: "report-management", label: "報表與分析", icon: , - permission: ["accounting.view", "inventory_report.view"], + permission: ["accounting.view", "inventory_report.view", "procurement_analysis.view"], children: [ { id: "accounting-report", @@ -278,6 +278,13 @@ export default function AuthenticatedLayout({ route: "/inventory/analysis", permission: "inventory_report.view", }, + { + id: "procurement-analysis", + label: "採購統計分析", + icon: , + route: "/procurement/analysis", + permission: "procurement_analysis.view", + }, { id: "inventory-traceability", label: "批號溯源", diff --git a/resources/js/Pages/Procurement/Analysis/Index.tsx b/resources/js/Pages/Procurement/Analysis/Index.tsx new file mode 100644 index 0000000..84a1d2d --- /dev/null +++ b/resources/js/Pages/Procurement/Analysis/Index.tsx @@ -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: }, + { key: "quantity", label: "數量分析", icon: }, + { key: "price", label: "價格趨勢", icon: }, +] as const; + +type TabKey = (typeof TABS)[number]["key"]; + +export default function ProcurementAnalysisIndex({ + kpis, + deliveryAnalysis, + quantityAnalysis, + priceTrendAnalysis, + vendors, + warehouses, + filters, +}: Props) { + const [activeTab, setActiveTab] = useState("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 = {}; + 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: , + color: "text-blue-600", + bgColor: "bg-blue-50", + borderColor: "border-blue-100", + }, + { + label: "進貨筆數", + tooltip: "期間內狀態為「已完成」的進貨單數量", + value: kpis.total_receipts, + description: "已完成進貨單", + icon: , + color: "text-emerald-600", + bgColor: "bg-emerald-50", + borderColor: "border-emerald-100", + }, + { + label: "平均交期", + tooltip: "真實進貨日期減去採購單日期的平均天數", + value: `${kpis.avg_delivery_days} 天`, + description: "下單到到貨平均天數", + icon: , + color: "text-amber-600", + bgColor: "bg-amber-50", + borderColor: "border-amber-100", + }, + { + label: "準時到貨率", + tooltip: "進貨單真實進貨日期早於或等於採購單預計到貨日的比例", + value: `${kpis.on_time_rate}%`, + description: "於預計交期內到貨", + icon: , + 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 ( + + + +
+ {/* 頁面標題 */} +
+

+ + 採購統計分析 +

+

+ 分析廠商供貨時效、進貨數量、單價趨勢,支援採購決策與成本控制 +

+
+ + {/* 篩選列 */} +
+
+
+ + setLocalFilters((f) => ({ ...f, date_from: e.target.value }))} + className="h-9 bg-white" + /> +
+
+ + setLocalFilters((f) => ({ ...f, date_to: e.target.value }))} + className="h-9 bg-white" + /> +
+
+ + 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="選擇廠商..." + /> +
+
+ + 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="選擇倉庫..." + /> +
+
+ + +
+
+
+ + {/* KPI 卡片列 */} + +
+ {kpiCards.map((card) => ( +
+
+
+ {card.icon} +
+
+
+ {card.label} + + + + + +

{card.tooltip}

+
+
+
+
{card.value}
+
{card.description}
+
+ ))} +
+
+ + {/* Tab 切換 */} +
+
+
+ {TABS.map((tab) => ( + + ))} +
+
+ +
+ {activeTab === "delivery" && ( + + )} + {activeTab === "quantity" && ( + + )} + {activeTab === "price" && ( + + )} +
+
+
+
+ ); +} + +// ===== Tab 1: 供貨時效 ===== +function DeliveryTab({ + vendorDelivery, + delayDistribution, +}: { + vendorDelivery: VendorDelivery[]; + delayDistribution: DelayDistribution[]; +}) { + if (vendorDelivery.length === 0) { + return ; + } + + const parsedDelayDistribution = useMemo(() => { + return delayDistribution.map(d => ({ ...d, count: Number(d.count) })); + }, [delayDistribution]); + + return ( +
+ +
+ {/* 廠商平均交期長條圖 */} +
+

+ + 廠商平均交期天數 + + + + + +

將進貨資料依廠商分組,計算「進貨日 - 採購日」的平均天數、極端值與準時率。預設由快到慢排序。

+
+
+

+
+ + + + + + { + const labels: Record = { + avg_days: "平均交期", + min_days: "最短", + max_days: "最長", + }; + return [`${value} 天`, labels[name] || name]; + }} + /> + ({ avg_days: "平均", min_days: "最短", max_days: "最長" }[v] || v)} /> + + + + + +
+
+ + {/* 交期分佈圓餅圖 */} +
+

+ + 交期分佈 +

+
+ + + + {parsedDelayDistribution.map((_, index) => ( + + ))} + + { + const total = parsedDelayDistribution.reduce((sum, item) => sum + item.count, 0); + const percent = total > 0 ? ((value / total) * 100).toFixed(0) : 0; + return [`${value} 筆 (${percent}%)`, "數量"]; + }} + /> + + + +
+
+
+ + {/* 廠商準時率排行表格 */} +
+

+ + 廠商準時到貨排行 + + + + + +

準時率 = (該廠商準時進貨筆數 / 總進貨筆數) * 100%

+
+
+

+
+ + + + + + + + + + + + + + {vendorDelivery.map((v, idx) => ( + + + + + + + + + + ))} + +
排名廠商進貨次數平均交期最短最長準時率
{idx + 1}{v.vendor_name}{v.total_count}{v.avg_days} 天{v.min_days} 天{v.max_days} 天 + = 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}% + +
+
+
+
+
+ ); +} + +// ===== Tab 2: 數量分析 ===== +function QuantityTab({ + monthlyTrend, + vendorShare, + productRanking, +}: { + monthlyTrend: MonthlyTrend[]; + vendorShare: VendorShare[]; + productRanking: ProductRanking[]; +}) { + if (monthlyTrend.length === 0 && vendorShare.length === 0) { + return ; + } + + const parsedVendorShare = useMemo(() => { + return vendorShare.map(d => ({ ...d, total_amount: Number(d.total_amount) })); + }, [vendorShare]); + + return ( +
+ + {/* 月度趨勢 */} +
+
+

+ + 月度進貨金額趨勢 + + + + + +

將所有已完成的進貨單依月份分組,加總總進貨數量與金額。

+
+
+

+
+ + + + + + + + + + `$${(v / 1000).toFixed(0)}k`} tick={{ fontSize: 12 }} /> + + { + if (name === "total_amount") return [`NT$ ${value.toLocaleString()}`, "進貨金額"]; + if (name === "receipt_count") return [`${value} 筆`, "進貨筆數"]; + return [value, name]; + }} + /> + + + +
+
+ + {/* 廠商佔比 */} +
+

+ + 廠商進貨金額佔比 +

+
+ + + + {parsedVendorShare.map((_, index) => ( + + ))} + + { + 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}%)`, "金額"]; + }} + /> + value.length > 6 ? value.slice(0, 6) + '…' : value} /> + + +
+
+
+ + {/* 商品進貨排行 */} +
+

+ + 商品進貨排行 Top 10 + + + + + +

依據商品分組,加總總進貨量與金額,並依金額高低反向排序出前 10 名。

+
+
+

+
+ + + + + + + + + + + + {productRanking.map((p, idx) => ( + + + + + + + + ))} + +
排名商品名稱商品編號進貨數量進貨金額
{idx + 1}{p.product_name}{p.product_code}{Number(p.total_quantity).toLocaleString()}NT$ {Number(p.total_amount).toLocaleString()}
+ {productRanking.length === 0 && ( +
無符合條件的資料
+ )} +
+
+
+
+ ); +} + +// ===== Tab 3: 價格趨勢 ===== +function PriceTab({ + priceTrend, + vendorComparison, +}: { + priceTrend: PriceTrendItem[]; + vendorComparison: VendorComparison[]; +}) { + if (priceTrend.length === 0 && vendorComparison.length === 0) { + return ; + } + + // 將 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 = { 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>((acc, item) => { + if (!acc[item.product_name]) acc[item.product_name] = []; + acc[item.product_name].push(item); + return acc; + }, {}); + + return ( +
+ + {/* 商品單價趨勢折線圖 */} + {chartData.length > 0 && ( +
+

+ + 商品月均單價趨勢(Top 10 商品) + + + + + +

先從期間內找出「進貨總金額最高的前 10 項商品」作為追蹤目標,計算它們每月的平均進貨單價繪製成折線圖。

+
+
+

+
+ + + + + `$${v}`} tick={{ fontSize: 12 }} /> + [ + `NT$ ${value.toLocaleString()}`, name + ]} + /> + + {productNames.slice(0, 8).map((name, idx) => ( + + ))} + + +
+
+ )} + + {/* 跨廠商比價表格 */} + {Object.keys(comparisonGrouped).length > 0 && ( +
+

+ + 跨廠商比價分析 + + + + + +

以進貨金額最高的前 10項商品為基準,列出各廠商過去期間的平均、最高、最低進貨單價以供比價,最低價將會標記「最優」。

+
+
+

+
+ {Object.entries(comparisonGrouped).map(([productName, items]) => ( +
+
+ {productName} +
+ + + + + + + + + + + + {items.map((item, idx) => { + const isLowest = item.avg_price === Math.min(...items.map((i) => i.avg_price)); + return ( + + + + + + + + ); + })} + +
廠商平均單價最低價最高價交易次數
+ {item.vendor_name} + {isLowest && ( + + 最優 + + )} + + NT$ {item.avg_price.toLocaleString()} + + NT$ {item.min_price.toLocaleString()} + + NT$ {item.max_price.toLocaleString()} + + {item.purchase_count} +
+
+ ))} +
+
+ )} +
+
+ ); +} + +// ===== 空狀態 ===== +function EmptyState({ message }: { message: string }) { + return ( +
+ +

{message}

+
+ ); +} diff --git a/resources/views/docs/api.blade.php b/resources/views/docs/api.blade.php index 39ff6b4..c095102 100644 --- a/resources/views/docs/api.blade.php +++ b/resources/views/docs/api.blade.php @@ -57,9 +57,9 @@ prose-headings:scroll-mt-20 prose-h1:text-4xl prose-h1:font-extrabold prose-h1:tracking-tight prose-pre:p-6 prose-pre:text-sm - prose-table:border prose-table:rounded-xl prose-table:overflow-hidden - prose-th:bg-slate-100 prose-th:p-4 - prose-td:p-4 prose-td:border-t prose-td:border-slate-100"> + 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:border prose-th:border-slate-300 prose-th:text-left prose-th:font-semibold + prose-td:p-4 prose-td:border prose-td:border-slate-200"> {!! $content !!}