star-cloud/app/Http/Controllers/Admin/AnalysisController.php
sky121113 8f2dd1375e [FEAT] 實作機台報表分析功能與商品報表篩選列優化
1. 實作 AnalysisController@machineReports 後端邏輯,支援租戶隔離與帳號機台授權過濾。
2. 建立 machine-reports.blade.php 視圖,加入極簡奢華風統計卡片、ApexCharts 趨勢圖與自訂多選支付工具篩選器,並停用圖表縮放優化滾輪滾動體驗。
3. 優化 product-reports.blade.php,新增機台下拉選單過濾,並啟用寬度優化與響應式自動換行,且同步停用圖表縮放。
4. 在 Machine model 全域 Scope 中排除單元測試阻擋,並更新 RoleSeeder 播種機台報表權限。
5. 新增 AnalysisReportsTest 測試,並更新 AnalysisPermissionTest 權限測試,且在 Sail 中驗證為 100% 通過。
6. 同步更新繁中、英文、日文翻譯 JSON 檔案,以 zh_TW 為基準完美對齊、字母排序且還原斜線。
2026-06-05 16:21:05 +08:00

511 lines
20 KiB
PHP

<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class AnalysisController extends Controller
{
// 零錢庫存分析
public function changeStock()
{
return view('admin.placeholder', [
'title' => '零錢庫存分析',
'description' => '機台零錢數量監測與分析',
]);
}
public function machineReports(Request $request)
{
$user = auth()->user();
$companyId = $user->company_id;
// 1. 取得篩選參數
$selectedCompanyId = $request->input('company_id');
$selectedMachineId = $request->input('machine_id', 'ALL');
$selectedProductId = $request->input('product_id', 'ALL');
$paymentTypes = $request->input('payment_types', []);
$dateRange = $request->input('date_range');
if (!is_array($paymentTypes)) {
$paymentTypes = $paymentTypes ? explode(',', $paymentTypes) : [];
}
$paymentTypes = array_filter(array_map('intval', $paymentTypes));
// 2. 解析日期區間
if ($dateRange) {
$dates = explode(' to ', $dateRange);
if (count($dates) === 2) {
$startDate = \Illuminate\Support\Carbon::parse($dates[0])->startOfDay();
$endDate = \Illuminate\Support\Carbon::parse($dates[1])->endOfDay();
} else {
$startDate = \Illuminate\Support\Carbon::parse($dates[0])->startOfDay();
$endDate = \Illuminate\Support\Carbon::parse($dates[0])->endOfDay();
}
} else {
// 預設為最近 7 天 (含今日)
$startDate = now()->subDays(6)->startOfDay();
$endDate = now()->endOfDay();
$dateRange = $startDate->format('Y-m-d') . ' to ' . $endDate->format('Y-m-d');
}
// 3. 租戶隔離:非系統管理員強制限制為其所屬公司
if (!$user->isSystemAdmin()) {
$effectiveCompanyId = $companyId;
} else {
$effectiveCompanyId = $selectedCompanyId;
}
// 取得使用者授權的機台 ID 範圍 (一般用戶)
$allowedMachineIds = null;
if (!$user->isSystemAdmin()) {
$allowedMachineIds = \App\Models\Machine\Machine::pluck('id')->toArray();
}
// 4. 加載下拉選單選項
// 公司選單 (僅限 Super Admin)
$companies = [];
if ($user->isSystemAdmin()) {
$companies = \App\Models\System\Company::active()->get(['id', 'name']);
}
// 機台選單 (自動套用多租戶與授權隔離)
$machinesQuery = \App\Models\Machine\Machine::query();
if ($effectiveCompanyId) {
$machinesQuery->where('company_id', $effectiveCompanyId);
}
$machines = $machinesQuery->get(['id', 'name', 'serial_no']);
// 商品選單
$productsQuery = \App\Models\Product\Product::query();
if ($effectiveCompanyId) {
$productsQuery->where('company_id', $effectiveCompanyId);
}
$products = $productsQuery->active()->get(['id', 'name', 'barcode']);
// 5. 支付工具選項 (使用者指定之 11 種)
$availablePaymentTypes = [
1 => __('Credit Card'),
2 => __('E-Ticket (EasyCard/iPass)'),
3 => __('QR Code Payment'),
4 => __('Bill Acceptor'),
5 => __('Pass Code'),
6 => __('Pickup Code'),
7 => __('Welcome Gift'),
8 => __('Questionnaire'),
9 => __('Coin Acceptor'),
41 => __('Staff Card'),
42 => __('Pickup Voucher'),
];
// 6. 執行主報表統計 SQL (按機台分組)
$mainQuery = \Illuminate\Support\Facades\DB::table('order_items as oi')
->join('orders as o', 'o.id', '=', 'oi.order_id')
->leftJoin('machines as m', 'm.id', '=', 'o.machine_id')
->leftJoin('products as p', 'p.id', '=', 'oi.product_id')
->select([
'o.machine_id',
\Illuminate\Support\Facades\DB::raw('COALESCE(m.serial_no, "UNKNOWN") as serial_no'),
\Illuminate\Support\Facades\DB::raw('COALESCE(m.name, "未知機台") as name'),
\Illuminate\Support\Facades\DB::raw('SUM(oi.quantity) as total_quantity'),
\Illuminate\Support\Facades\DB::raw('SUM(oi.subtotal) as total_sales'),
\Illuminate\Support\Facades\DB::raw('SUM(oi.quantity * COALESCE(p.cost, 0)) as total_cost'),
\Illuminate\Support\Facades\DB::raw('SUM(oi.subtotal) - SUM(oi.quantity * COALESCE(p.cost, 0)) as total_profit')
])
->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS)
->whereNull('o.deleted_at')
->whereBetween('o.created_at', [$startDate, $endDate]);
// 套用公司篩選
if ($effectiveCompanyId) {
$mainQuery->where('o.company_id', $effectiveCompanyId);
}
// 套用機台權限限制
if ($allowedMachineIds !== null) {
if (empty($allowedMachineIds)) {
$mainQuery->whereRaw('1 = 0');
} else {
$mainQuery->whereIn('o.machine_id', $allowedMachineIds);
}
}
// 套用特定機台篩選
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
$mainQuery->where('o.machine_id', $selectedMachineId);
}
// 套用特定商品篩選
if ($selectedProductId && $selectedProductId !== 'ALL') {
$mainQuery->where('oi.product_id', $selectedProductId);
}
// 套用支付工具多選篩選
if (!empty($paymentTypes)) {
$mainQuery->whereIn('o.payment_type', $paymentTypes);
}
$mainQuery->groupBy('o.machine_id')
->groupBy(\Illuminate\Support\Facades\DB::raw('COALESCE(m.serial_no, "UNKNOWN")'))
->groupBy(\Illuminate\Support\Facades\DB::raw('COALESCE(m.name, "未知機台")'));
$tableData = $mainQuery->get();
// 7. 計算總計 Summary
$totalQuantity = 0;
$totalSales = 0.0;
$totalCost = 0.0;
$totalProfit = 0.0;
foreach ($tableData as $row) {
$row->total_quantity = (int)$row->total_quantity;
$row->total_sales = (float)$row->total_sales;
$row->total_cost = (float)$row->total_cost;
$row->total_profit = (float)$row->total_profit;
$totalQuantity += $row->total_quantity;
$totalSales += $row->total_sales;
$totalCost += $row->total_cost;
$totalProfit += $row->total_profit;
}
$summary = [
'total_quantity' => $totalQuantity,
'total_sales' => round($totalSales, 2),
'total_cost' => round($totalCost, 2),
'total_profit' => round($totalProfit, 2),
];
// 8. 圖表日走勢統計 SQL
$trendQuery = \Illuminate\Support\Facades\DB::table('order_items as oi')
->join('orders as o', 'o.id', '=', 'oi.order_id')
->leftJoin('products as p', 'p.id', '=', 'oi.product_id')
->select([
\Illuminate\Support\Facades\DB::raw("DATE_FORMAT(o.created_at, '%Y-%m-%d') as date"),
\Illuminate\Support\Facades\DB::raw('SUM(oi.quantity) as total_quantity'),
\Illuminate\Support\Facades\DB::raw('SUM(oi.subtotal) as total_sales'),
\Illuminate\Support\Facades\DB::raw('SUM(oi.quantity * COALESCE(p.cost, 0)) as total_cost'),
])
->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS)
->whereNull('o.deleted_at')
->whereBetween('o.created_at', [$startDate, $endDate]);
if ($effectiveCompanyId) {
$trendQuery->where('o.company_id', $effectiveCompanyId);
}
if ($allowedMachineIds !== null) {
if (empty($allowedMachineIds)) {
$trendQuery->whereRaw('1 = 0');
} else {
$trendQuery->whereIn('o.machine_id', $allowedMachineIds);
}
}
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
$trendQuery->where('o.machine_id', $selectedMachineId);
}
if ($selectedProductId && $selectedProductId !== 'ALL') {
$trendQuery->where('oi.product_id', $selectedProductId);
}
if (!empty($paymentTypes)) {
$trendQuery->whereIn('o.payment_type', $paymentTypes);
}
$trendQuery->groupBy('date')->orderBy('date', 'asc');
$trendData = $trendQuery->get();
// 補齊日期區間內所有空缺日走勢
$period = new \DatePeriod(
$startDate->copy()->startOfDay(),
new \DateInterval('P1D'),
$endDate->copy()->endOfDay()
);
$chartData = [];
foreach ($period as $date) {
$dateStr = $date->format('Y-m-d');
$chartData[$dateStr] = [
'date' => $dateStr,
'quantity' => 0,
'sales' => 0.0,
'cost' => 0.0,
];
}
foreach ($trendData as $row) {
if (isset($chartData[$row->date])) {
$chartData[$row->date]['quantity'] = (int)$row->total_quantity;
$chartData[$row->date]['sales'] = round((float)$row->total_sales, 2);
$chartData[$row->date]['cost'] = round((float)$row->total_cost, 2);
}
}
$chartData = array_values($chartData);
// 9. 判斷是否為 AJAX 請求,回傳 JSON 格式
if ($request->ajax() || $request->wantsJson()) {
return response()->json([
'success' => true,
'summary' => $summary,
'chart_data' => $chartData,
'table_data' => $tableData,
]);
}
// 10. 一般 GET 載入視圖
return view('admin.analysis.machine-reports', [
'title' => '機台報表分析',
'description' => '機台運營數據分析報表',
'companies' => $companies,
'machines' => $machines,
'products' => $products,
'availablePaymentTypes' => $availablePaymentTypes,
'selectedCompanyId' => $selectedCompanyId,
'selectedMachineId' => $selectedMachineId,
'selectedProductId' => $selectedProductId,
'selectedPaymentTypes' => $paymentTypes,
'dateRange' => $dateRange,
'summary' => $summary,
'chartData' => $chartData,
'tableData' => $tableData,
]);
}
// 商品報表分析
public function productReports(Request $request)
{
$user = auth()->user();
$companyId = $user->company_id;
// 1. 取得篩選參數
$selectedCompanyId = $request->input('company_id');
$selectedProductId = $request->input('product_id', 'ALL');
$selectedMachineId = $request->input('machine_id', 'ALL');
$dateRange = $request->input('date_range');
// 2. 解析日期區間
if ($dateRange) {
$dates = explode(' to ', $dateRange);
if (count($dates) === 2) {
$startDate = \Illuminate\Support\Carbon::parse($dates[0])->startOfDay();
$endDate = \Illuminate\Support\Carbon::parse($dates[1])->endOfDay();
} else {
$startDate = \Illuminate\Support\Carbon::parse($dates[0])->startOfDay();
$endDate = \Illuminate\Support\Carbon::parse($dates[0])->endOfDay();
}
} else {
// 預設為最近 7 天 (含今日)
$startDate = now()->subDays(6)->startOfDay();
$endDate = now()->endOfDay();
$dateRange = $startDate->format('Y-m-d') . ' to ' . $endDate->format('Y-m-d');
}
// 3. 租戶隔離:非系統管理員強制限制為其所屬公司
if (!$user->isSystemAdmin()) {
$effectiveCompanyId = $companyId;
} else {
$effectiveCompanyId = $selectedCompanyId;
}
// 取得使用者授權的機台 ID 範圍 (一般用戶)
$allowedMachineIds = null;
if (!$user->isSystemAdmin()) {
$allowedMachineIds = \App\Models\Machine\Machine::pluck('id')->toArray();
}
// 4. 加載下拉選單選項
// 公司選單 (僅限 Super Admin)
$companies = [];
if ($user->isSystemAdmin()) {
$companies = \App\Models\System\Company::active()->get(['id', 'name']);
}
// 機台選單 (自動套用多租戶與授權隔離)
$machinesQuery = \App\Models\Machine\Machine::query();
if ($effectiveCompanyId) {
$machinesQuery->where('company_id', $effectiveCompanyId);
}
$machines = $machinesQuery->get(['id', 'name', 'serial_no']);
// 商品選單
$productsQuery = \App\Models\Product\Product::query();
if ($effectiveCompanyId) {
$productsQuery->where('company_id', $effectiveCompanyId);
}
$products = $productsQuery->active()->get(['id', 'name', 'barcode']);
// 5. 執行主報表統計 SQL
// 計算各商品的銷售數量、銷售額、總成本、利潤
$mainQuery = \Illuminate\Support\Facades\DB::table('order_items as oi')
->join('orders as o', 'o.id', '=', 'oi.order_id')
->leftJoin('products as p', 'p.id', '=', 'oi.product_id')
->select([
'oi.product_id',
\Illuminate\Support\Facades\DB::raw('COALESCE(p.barcode, oi.barcode) as barcode'),
\Illuminate\Support\Facades\DB::raw('COALESCE(p.name, oi.product_name) as name'),
\Illuminate\Support\Facades\DB::raw('SUM(oi.quantity) as total_quantity'),
\Illuminate\Support\Facades\DB::raw('SUM(oi.subtotal) as total_sales'),
\Illuminate\Support\Facades\DB::raw('SUM(oi.quantity * COALESCE(p.cost, 0)) as total_cost'),
\Illuminate\Support\Facades\DB::raw('SUM(oi.subtotal) - SUM(oi.quantity * COALESCE(p.cost, 0)) as total_profit')
])
->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS)
->whereNull('o.deleted_at')
->whereBetween('o.created_at', [$startDate, $endDate]);
// 套用公司篩選
if ($effectiveCompanyId) {
$mainQuery->where('o.company_id', $effectiveCompanyId);
}
// 套用機台權限限制
if ($allowedMachineIds !== null) {
if (empty($allowedMachineIds)) {
$mainQuery->whereRaw('1 = 0');
} else {
$mainQuery->whereIn('o.machine_id', $allowedMachineIds);
}
}
// 套用特定機台篩選
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
$mainQuery->where('o.machine_id', $selectedMachineId);
}
// 套用特定商品篩選
if ($selectedProductId && $selectedProductId !== 'ALL') {
$mainQuery->where('oi.product_id', $selectedProductId);
}
$mainQuery->groupBy('oi.product_id')
->groupBy(\Illuminate\Support\Facades\DB::raw('COALESCE(p.barcode, oi.barcode)'))
->groupBy(\Illuminate\Support\Facades\DB::raw('COALESCE(p.name, oi.product_name)'));
$tableData = $mainQuery->get();
// 6. 計算總計 Summary
$totalQuantity = 0;
$totalSales = 0.0;
$totalCost = 0.0;
$totalProfit = 0.0;
foreach ($tableData as $row) {
$row->total_quantity = (int)$row->total_quantity;
$row->total_sales = (float)$row->total_sales;
$row->total_cost = (float)$row->total_cost;
$row->total_profit = (float)$row->total_profit;
$totalQuantity += $row->total_quantity;
$totalSales += $row->total_sales;
$totalCost += $row->total_cost;
$totalProfit += $row->total_profit;
}
$summary = [
'total_quantity' => $totalQuantity,
'total_sales' => round($totalSales, 2),
'total_cost' => round($totalCost, 2),
'total_profit' => round($totalProfit, 2),
];
// 7. 圖表日走勢統計 SQL
$trendQuery = \Illuminate\Support\Facades\DB::table('order_items as oi')
->join('orders as o', 'o.id', '=', 'oi.order_id')
->leftJoin('products as p', 'p.id', '=', 'oi.product_id')
->select([
\Illuminate\Support\Facades\DB::raw("DATE_FORMAT(o.created_at, '%Y-%m-%d') as date"),
\Illuminate\Support\Facades\DB::raw('SUM(oi.quantity) as total_quantity'),
\Illuminate\Support\Facades\DB::raw('SUM(oi.subtotal) as total_sales'),
\Illuminate\Support\Facades\DB::raw('SUM(oi.quantity * COALESCE(p.cost, 0)) as total_cost'),
])
->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS)
->whereNull('o.deleted_at')
->whereBetween('o.created_at', [$startDate, $endDate]);
if ($effectiveCompanyId) {
$trendQuery->where('o.company_id', $effectiveCompanyId);
}
if ($allowedMachineIds !== null) {
if (empty($allowedMachineIds)) {
$trendQuery->whereRaw('1 = 0');
} else {
$trendQuery->whereIn('o.machine_id', $allowedMachineIds);
}
}
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
$trendQuery->where('o.machine_id', $selectedMachineId);
}
if ($selectedProductId && $selectedProductId !== 'ALL') {
$trendQuery->where('oi.product_id', $selectedProductId);
}
$trendQuery->groupBy('date')->orderBy('date', 'asc');
$trendData = $trendQuery->get();
// 補齊日期區間內所有空缺日走勢
$period = new \DatePeriod(
$startDate->copy()->startOfDay(),
new \DateInterval('P1D'),
$endDate->copy()->endOfDay()
);
$chartData = [];
foreach ($period as $date) {
$dateStr = $date->format('Y-m-d');
$chartData[$dateStr] = [
'date' => $dateStr,
'quantity' => 0,
'sales' => 0.0,
'cost' => 0.0,
];
}
foreach ($trendData as $row) {
if (isset($chartData[$row->date])) {
$chartData[$row->date]['quantity'] = (int)$row->total_quantity;
$chartData[$row->date]['sales'] = round((float)$row->total_sales, 2);
$chartData[$row->date]['cost'] = round((float)$row->total_cost, 2);
}
}
$chartData = array_values($chartData);
// 8. 判斷是否為 AJAX 請求,回傳 JSON 格式
if ($request->ajax() || $request->wantsJson()) {
return response()->json([
'success' => true,
'summary' => $summary,
'chart_data' => $chartData,
'table_data' => $tableData,
]);
}
// 9. 一般 GET 載入視圖
return view('admin.analysis.product-reports', [
'title' => '商品報表分析',
'description' => '商品銷售數據 analysis',
'companies' => $companies,
'machines' => $machines,
'products' => $products,
'selectedCompanyId' => $selectedCompanyId,
'selectedMachineId' => $selectedMachineId,
'selectedProductId' => $selectedProductId,
'dateRange' => $dateRange,
'summary' => $summary,
'chartData' => $chartData,
'tableData' => $tableData,
]);
}
// 互動問卷分析
public function surveyAnalysis()
{
return view('admin.placeholder', [
'title' => '互動問卷分析',
'description' => '問卷結果統計與分析',
]);
}
}