diff --git a/app/Http/Controllers/Admin/AnalysisController.php b/app/Http/Controllers/Admin/AnalysisController.php index 3b913ab..4357105 100644 --- a/app/Http/Controllers/Admin/AnalysisController.php +++ b/app/Http/Controllers/Admin/AnalysisController.php @@ -16,12 +16,260 @@ class AnalysisController extends Controller ]); } - // 機台報表分析 - public function machineReports() + public function machineReports(Request $request) { - return view('admin.placeholder', [ + $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, ]); } @@ -34,6 +282,7 @@ class AnalysisController extends Controller // 1. 取得篩選參數 $selectedCompanyId = $request->input('company_id'); $selectedProductId = $request->input('product_id', 'ALL'); + $selectedMachineId = $request->input('machine_id', 'ALL'); $dateRange = $request->input('date_range'); // 2. 解析日期區間 @@ -60,6 +309,12 @@ class AnalysisController extends Controller $effectiveCompanyId = $selectedCompanyId; } + // 取得使用者授權的機台 ID 範圍 (一般用戶) + $allowedMachineIds = null; + if (!$user->isSystemAdmin()) { + $allowedMachineIds = \App\Models\Machine\Machine::pluck('id')->toArray(); + } + // 4. 加載下拉選單選項 // 公司選單 (僅限 Super Admin) $companies = []; @@ -67,6 +322,13 @@ class AnalysisController extends Controller $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) { @@ -97,6 +359,20 @@ class AnalysisController extends Controller $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); @@ -150,6 +426,18 @@ class AnalysisController extends Controller $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); } @@ -185,7 +473,7 @@ class AnalysisController extends Controller $chartData = array_values($chartData); // 8. 判斷是否為 AJAX 請求,回傳 JSON 格式 - if ($request->ajax()) { + if ($request->ajax() || $request->wantsJson()) { return response()->json([ 'success' => true, 'summary' => $summary, @@ -197,10 +485,12 @@ class AnalysisController extends Controller // 9. 一般 GET 載入視圖 return view('admin.analysis.product-reports', [ 'title' => '商品報表分析', - 'description' => '商品銷售數據分析', + 'description' => '商品銷售數據 analysis', 'companies' => $companies, + 'machines' => $machines, 'products' => $products, 'selectedCompanyId' => $selectedCompanyId, + 'selectedMachineId' => $selectedMachineId, 'selectedProductId' => $selectedProductId, 'dateRange' => $dateRange, 'summary' => $summary, diff --git a/app/Models/Machine/Machine.php b/app/Models/Machine/Machine.php index 378fcd6..ee1380a 100644 --- a/app/Models/Machine/Machine.php +++ b/app/Models/Machine/Machine.php @@ -16,8 +16,8 @@ class Machine extends Model // 權限隔離:一般帳號登入時只能看到自己被分配的機台 static::addGlobalScope('machine_access', function (\Illuminate\Database\Eloquent\Builder $builder) { $user = auth()->user(); - // 如果是在 Console、或是系統管理員,則不限制 (可看所有機台) - if (app()->runningInConsole() || !$user || $user->isSystemAdmin()) { + // 如果是在 Console(且非單元測試)、或是系統管理員,則不限制 (可看所有機台) + if ((app()->runningInConsole() && !app()->runningUnitTests()) || !$user || $user->isSystemAdmin()) { return; } diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index 50859d9..2a98060 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -106,6 +106,7 @@ class RoleSeeder extends Seeder 'menu.sales.pass-codes', 'menu.sales.store-gifts', 'menu.analysis', + 'menu.analysis.machine-reports', 'menu.analysis.product-reports', 'menu.audit', 'menu.data-config', diff --git a/lang/en.json b/lang/en.json index a732410..72a711a 100644 --- a/lang/en.json +++ b/lang/en.json @@ -735,6 +735,7 @@ "Fill in the product details below": "Fill in the product details below", "Filter": "Filter", "Filter by Company": "Filter by Company", + "Filter by Payment Method": "Filter by Payment Method", "Filter by Warehouse Presence": "Filter by Warehouse Presence", "Firmware Version": "Firmware Version", "Firmware identification details": "Firmware identification details", @@ -995,6 +996,7 @@ "Low Stock Alerts": "Low Stock Alerts", "Low stock and out-of-stock alerts": "Low stock and out-of-stock alerts", "Loyalty & Features": "Loyalty & Features", + "M2W": "M2W", "Machine": "Machine", "Machine / Date": "Machine / Date", "Machine / Employee": "Machine / Employee", @@ -1034,9 +1036,11 @@ "Machine Reboot": "Machine Reboot", "Machine Registry": "Machine Registry", "Machine Replenishment": "Machine Replenishment", + "Machine Report Analysis": "Machine Report Analysis", "Machine Reports": "Machine Reports", "Machine Restart": "Machine Restart", "Machine Return": "Machine Return", + "Machine Sales & Profit Trend": "Machine Sales & Profit Trend", "Machine Serial": "Machine Serial", "Machine Serial No": "Machine Serial No", "Machine Settings": "Machine Settings", @@ -1064,7 +1068,6 @@ "Machine to Warehouse": "Machine to Warehouse", "Machine to Warehouse Return": "Machine to Warehouse Return", "Machine to warehouse returns": "Machine to warehouse returns", - "M2W": "M2W", "Machine updated successfully.": "Machine updated successfully.", "Machine-Specific Webhook": "Machine-Specific Webhook", "Machines": "Machines", @@ -1244,6 +1247,7 @@ "No location set": "No location set", "No login history yet": "No login history yet", "No logs found": "No logs found", + "No machine data matching search criteria": "No machine data matching search criteria", "No machines assigned": "No machines assigned", "No machines available": "No machines available", "No machines available in this company.": "No machines available in this company.", @@ -1750,6 +1754,7 @@ "Search logs...": "Search logs...", "Search machine name or SN...": "Search machine name or SN...", "Search machine name, S/N, or location...": "Search machine name, S/N, or location...", + "Search machine name, serial no...": "Search machine name, serial no...", "Search machines by name or serial...": "Search machines by name or serial...", "Search machines...": "Search machines...", "Search models...": "Search models...", @@ -2178,6 +2183,7 @@ "Vending Machine Product Replenishment": "Vending Machine Product Replenishment", "Vending Page": "Vending Page", "Vending machine reported a submachine hardware error.": "Vending machine reported a submachine hardware error.", + "Vending machine revenue and payment analysis report": "Vending machine revenue and payment analysis report", "Venue Management": "Venue Management", "Verified": "Verified", "Verified Only": "Verified Only", @@ -2202,6 +2208,7 @@ "Visual overview of all machine locations": "Visual overview of all machine locations", "Void": "Void", "Void Invoice": "Void Invoice", + "W2W": "W2W", "Waiting": "Waiting", "Waiting for Payment": "Waiting for Payment", "Warehouse": "Warehouse", @@ -2229,7 +2236,6 @@ "Warehouse to Warehouse": "Warehouse to Warehouse", "Warehouse to Warehouse Transfer": "Warehouse to Warehouse Transfer", "Warehouse to warehouse transfers": "Warehouse to warehouse transfers", - "W2W": "W2W", "Warehouse updated successfully": "Warehouse updated successfully", "Warehouse updated successfully.": "Warehouse updated successfully.", "Warning": "Warning", diff --git a/lang/ja.json b/lang/ja.json index 1c076fb..e0f1933 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -735,6 +735,7 @@ "Fill in the product details below": "以下に商品の詳細情報を入力してください", "Filter": "フィルター", "Filter by Company": "Filter by Company", + "Filter by Payment Method": "決済方法でフィルター", "Filter by Warehouse Presence": "倉庫の在庫状況で絞り込む", "Firmware Version": "ファームウェアバージョン", "Firmware identification details": "Firmware identification details", @@ -995,6 +996,7 @@ "Low Stock Alerts": "低在庫アラート", "Low stock and out-of-stock alerts": "低在庫と欠品のアラート", "Loyalty & Features": "マーケティング・ポイント", + "M2W": "機器から倉庫", "Machine": "自販機", "Machine / Date": "機器 / 日付", "Machine / Employee": "機器 / 従業員", @@ -1034,9 +1036,11 @@ "Machine Reboot": "機器アプリ再起動", "Machine Registry": "機器リスト", "Machine Replenishment": "機器補充伝票", + "Machine Report Analysis": "マシン別売上レポート", "Machine Reports": "機器レポート", "Machine Restart": "機器アプリ再起動", "Machine Return": "機器返却", + "Machine Sales & Profit Trend": "マシン別売上・利益トレンド", "Machine Serial": "機器シリアル", "Machine Serial No": "機器シリアル番号", "Machine Settings": "機器設定", @@ -1064,7 +1068,6 @@ "Machine to Warehouse": "機器から倉庫", "Machine to Warehouse Return": "自販機から倉庫への返品", "Machine to warehouse returns": "機器から倉庫への返却", - "M2W": "機器から倉庫", "Machine updated successfully.": "機器が正常に更新されました。", "Machine-Specific Webhook": "機器専用 Webhook", "Machines": "機器管理", @@ -1244,6 +1247,7 @@ "No location set": "場所が設定されていません", "No login history yet": "ログイン履歴はまだありません", "No logs found": "ログが見つかりません", + "No machine data matching search criteria": "検索条件に一致するマシンデータはありません", "No machines assigned": "機器が割り当てられていません", "No machines available": "利用可能な機器がありません", "No machines available in this company.": "この会社で利用可能な機器がありません。", @@ -1750,6 +1754,7 @@ "Search logs...": "ログを検索...", "Search machine name or SN...": "機器名またはシリアル番号で検索...", "Search machine name, S/N, or location...": "Search machine name, S/N, or location...", + "Search machine name, serial no...": "マシン名、シリアル番号で検索...", "Search machines by name or serial...": "機器名またはシリアルで検索...", "Search machines...": "機器を検索...", "Search models...": "モデルを検索...", @@ -2178,6 +2183,7 @@ "Vending Machine Product Replenishment": "自販機商品補充", "Vending Page": "販売ページ", "Vending machine reported a submachine hardware error.": "マシンが下位機のハードウェア動作エラーを報告しました。", + "Vending machine revenue and payment analysis report": "自販機売上・決済方法分析レポート", "Venue Management": "施設管理", "Verified": "検証済み", "Verified Only": "検証済みのみ", @@ -2202,6 +2208,7 @@ "Visual overview of all machine locations": "すべての機器の位置の視覚的概要", "Void": "無効化 (作廢)", "Void Invoice": "領収書無効化", + "W2W": "倉庫間", "Waiting": "待機中", "Waiting for Payment": "支払待機中", "Warehouse": "倉庫", @@ -2229,7 +2236,6 @@ "Warehouse to Warehouse": "倉庫から倉庫", "Warehouse to Warehouse Transfer": "倉庫間移動", "Warehouse to warehouse transfers": "倉庫から倉庫への在庫移動", - "W2W": "倉庫間", "Warehouse updated successfully": "倉庫が正常に更新されました", "Warehouse updated successfully.": "倉庫が正常に更新されました。", "Warning": "期限間近", diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 728130d..9130367 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -735,6 +735,7 @@ "Fill in the product details below": "請在下方填寫商品的詳細資訊", "Filter": "篩選", "Filter by Company": "依公司篩選", + "Filter by Payment Method": "篩選支付工具", "Filter by Warehouse Presence": "依據倉庫存貨篩選", "Firmware Version": "韌體版本", "Firmware identification details": "韌體識別詳情", @@ -995,6 +996,7 @@ "Low Stock Alerts": "低庫存警示", "Low stock and out-of-stock alerts": "低庫存與缺貨警示", "Loyalty & Features": "行銷與點數", + "M2W": "機對倉", "Machine": "機台", "Machine / Date": "機台 / 日期", "Machine / Employee": "機台 / 員工", @@ -1034,9 +1036,11 @@ "Machine Reboot": "機台 APP 重啟", "Machine Registry": "機台清冊", "Machine Replenishment": "機台補貨單", + "Machine Report Analysis": "機台報表分析", "Machine Reports": "機台報表", "Machine Restart": "機台 APP 重啟", "Machine Return": "機台退庫", + "Machine Sales & Profit Trend": "機台銷售與利潤趨勢", "Machine Serial": "機台序號", "Machine Serial No": "機台序號", "Machine Settings": "機台設定", @@ -1064,7 +1068,6 @@ "Machine to Warehouse": "機台對倉庫", "Machine to Warehouse Return": "機台退庫至倉庫", "Machine to warehouse returns": "機台退回倉庫", - "M2W": "機對倉", "Machine updated successfully.": "機台更新成功。", "Machine-Specific Webhook": "機台專屬 Webhook", "Machines": "機台管理", @@ -1244,6 +1247,7 @@ "No location set": "尚未設定位置", "No login history yet": "尚無登入紀錄", "No logs found": "暫無相關日誌", + "No machine data matching search criteria": "無符合搜尋條件的機台數據", "No machines assigned": "未分配機台", "No machines available": "目前沒有可供分配的機台", "No machines available in this company.": "此客戶目前沒有可供分配的機台。", @@ -1750,6 +1754,7 @@ "Search logs...": "搜尋紀錄...", "Search machine name or SN...": "搜尋機台名稱或序號...", "Search machine name, S/N, or location...": "搜尋機台名稱、序號 (S/N) 或位置...", + "Search machine name, serial no...": "搜尋機台名稱、編號...", "Search machines by name or serial...": "搜尋機台名稱或序號...", "Search machines...": "搜尋機台...", "Search models...": "搜尋型號...", @@ -2178,6 +2183,7 @@ "Vending Machine Product Replenishment": "機台商品補貨", "Vending Page": "販賣頁", "Vending machine reported a submachine hardware error.": "機台回報下位機硬體運作錯誤。", + "Vending machine revenue and payment analysis report": "販賣機營運與支付方式分析", "Venue Management": "場地管理", "Verified": "驗證成功", "Verified Only": "僅限驗證", @@ -2202,6 +2208,7 @@ "Visual overview of all machine locations": "所有機台位置的視覺化概覽", "Void": "作廢", "Void Invoice": "作廢發票", + "W2W": "倉對倉", "Waiting": "等待中", "Waiting for Payment": "等待付款", "Warehouse": "倉庫", @@ -2229,7 +2236,6 @@ "Warehouse to Warehouse": "倉庫對倉庫", "Warehouse to Warehouse Transfer": "倉庫至倉庫調撥", "Warehouse to warehouse transfers": "倉庫對倉庫調撥", - "W2W": "倉對倉", "Warehouse updated successfully": "倉庫更新成功", "Warehouse updated successfully.": "倉庫已成功更新。", "Warning": "警告", diff --git a/resources/views/admin/analysis/machine-reports.blade.php b/resources/views/admin/analysis/machine-reports.blade.php new file mode 100644 index 0000000..b23f69b --- /dev/null +++ b/resources/views/admin/analysis/machine-reports.blade.php @@ -0,0 +1,1144 @@ +@extends('layouts.admin') + +@section('scripts') + +@endsection + +@section('content') +
+ + + +
+ + {{ __('Real-time operational data synchronized') }} +
+
+ + +
+ +
+
+
+
+

{{ __('Sales Quantity') }}

+

-

+
+
+ + + +
+
+
+ + {{ __('Product physical shipment count') }} +
+
+ + +
+
+
+
+

{{ __('Total Sales Amount') }}

+

-

+
+
+ + + +
+
+
+ + {{ __('Total transaction amount of successful payments') }} +
+
+ + +
+
+
+
+

{{ __('Total Product Cost') }}

+

-

+
+
+ + + +
+
+
+ + {{ __('Accumulated dynamic product purchase cost') }} +
+
+ + +
+
+
+
+

{{ __('Total Sales Profit') }}

+

-

+
+
+ + + +
+
+
+ + +
+
+
+ + +
+
+ + +
+ {{ __('Quick Filter') }} + +
+ + +
+ +
+
+ + + + +
+
+ + + @if(auth()->user()->isSystemAdmin()) +
+ + @foreach($companies as $c) + + @endforeach + +
+ @endif + + +
+ + @foreach($machines as $m) + + @endforeach + +
+ + +
+ + @foreach($products as $p) + + @endforeach + +
+ + +
+ +
+
+ +
+
+ + +
+ +
+
+
+

{{ __('Machine Sales & Profit Trend') }}

+

{{ __('Daily operational variance curve for selected timeframe') }}

+
+
+
+ + {{ __('Sales Amount') }} +
+
+ + {{ __('Product Cost') }} +
+
+ + {{ __('Sales Quantity') }} +
+
+
+ +
+
+
+
+
+ + +
+ +
+ + +
+
+

{{ __('Detailed Analysis Data') }}

+

+
+ +
+ +
+ + + + + + + +
+ + +
+ + + +
+ + + + +
+
+
+
+ + + + + +
+ + + + +
+ + +
+ + +
+ +
+ {{ __('Show') }} +
+ +
+ +
+
+
+ + +

+ + + {{ __('to') }} + + + / + + {{ __('items') }} +

+
+ + +
+ + + + +
+ +
+ +
+
+ + + +
+ +
+ +
+
+ +
+ + +@endsection diff --git a/resources/views/admin/analysis/product-reports.blade.php b/resources/views/admin/analysis/product-reports.blade.php index a9baf00..0aa10e4 100644 --- a/resources/views/admin/analysis/product-reports.blade.php +++ b/resources/views/admin/analysis/product-reports.blade.php @@ -5,7 +5,7 @@ @endsection @section('content') -
+
-
+
@@ -123,7 +123,7 @@
-
+
@@ -138,7 +138,7 @@ @if(auth()->user()->isSystemAdmin()) -
+
@foreach($companies as $c) @@ -148,8 +148,20 @@
@endif + +
+ + @foreach($machines as $m) + + @endforeach + +
+ -
+
@foreach($products as $p) @@ -525,7 +537,7 @@ /** * Product Report Dashboard Alpine Component */ - window.productReportApp = function (initialSummary, initialChartData, initialTableData, initialDateRange, initialProductId, initialCompanyId) { + window.productReportApp = function (initialSummary, initialChartData, initialTableData, initialDateRange, initialProductId, initialCompanyId, initialMachineId) { return { summary: initialSummary || { total_quantity: 0, total_sales: 0.0, total_cost: 0.0, total_profit: 0.0 }, chartData: initialChartData || [], @@ -533,6 +545,7 @@ dateRange: initialDateRange || '', selectedProductId: initialProductId || 'ALL', selectedCompanyId: initialCompanyId || '', + selectedMachineId: initialMachineId || 'ALL', loading: false, chart: null, fp: null, @@ -666,6 +679,7 @@ const url = new URL(window.location.href); url.searchParams.set('date_range', this.dateRange); url.searchParams.set('product_id', this.selectedProductId); + url.searchParams.set('machine_id', this.selectedMachineId); if (this.selectedCompanyId) { url.searchParams.set('company_id', this.selectedCompanyId); } @@ -936,6 +950,9 @@ toolbar: { show: false }, + zoom: { + enabled: false + }, fontFamily: 'Outfit, Plus Jakarta Sans, sans-serif', animations: { enabled: true, diff --git a/tests/Feature/Admin/AnalysisPermissionTest.php b/tests/Feature/Admin/AnalysisPermissionTest.php index b52eaa7..ac5e34f 100644 --- a/tests/Feature/Admin/AnalysisPermissionTest.php +++ b/tests/Feature/Admin/AnalysisPermissionTest.php @@ -106,18 +106,20 @@ class AnalysisPermissionTest extends TestCase /** * 測試租戶管理員只能訪問商品報表,其他 3 個路由會回傳 403 越權錯誤 */ - public function test_tenant_admin_can_only_access_product_reports() + public function test_tenant_admin_can_access_allowed_reports() { $this->actingAs($this->tenantAdmin); - // 1. 可以正常訪問商品報表 + // 1. 可以正常訪問商品報表與機台報表 $response = $this->get(route('admin.analysis.product-reports')); $response->assertStatus(200); - // 2. 其他 3 個路由皆應回傳 403 Forbidden 越權錯誤 + $response = $this->get(route('admin.analysis.machine-reports')); + $response->assertStatus(200); + + // 2. 其他 2 個路由皆應回傳 403 Forbidden 越權錯誤 $forbiddenRoutes = [ 'admin.analysis.change-stock', - 'admin.analysis.machine-reports', 'admin.analysis.survey-analysis', ]; diff --git a/tests/Feature/Admin/AnalysisReportsTest.php b/tests/Feature/Admin/AnalysisReportsTest.php new file mode 100644 index 0000000..c623055 --- /dev/null +++ b/tests/Feature/Admin/AnalysisReportsTest.php @@ -0,0 +1,274 @@ +withoutExceptionHandling(); + + // 1. Seed Roles & Permissions + $this->artisan('db:seed', ['--class' => 'RoleSeeder']); + + // 2. Create Company + $this->company = Company::create([ + 'name' => 'Report Test Company', + 'code' => 'REPTEST', + 'status' => 1, + ]); + + // Register SQLite compatible DATE_FORMAT function + if (\Illuminate\Support\Facades\Schema::getConnection()->getDriverName() === 'sqlite') { + $pdo = \Illuminate\Support\Facades\Schema::getConnection()->getPdo(); + $pdo->sqliteCreateFunction('DATE_FORMAT', function ($date, $format) { + if (empty($date)) return null; + $phpFormat = str_replace( + ['%Y', '%m', '%d', '%H', '%i', '%s'], + ['Y', 'm', 'd', 'H', 'i', 's'], + $format + ); + return date($phpFormat, strtotime($date)); + }, 2); + } + + // 3. Create Super Admin + $this->superAdmin = User::create([ + 'name' => 'Super Admin', + 'username' => 'super_reports_test', + 'email' => 'superadmin@example.com', + 'password' => bcrypt('password'), + 'company_id' => null, + ]); + $this->superAdmin->assignRole('super-admin'); + + // 4. Create Tenant User + $this->tenantUser = User::create([ + 'name' => 'Tenant User', + 'username' => 'tenant_reports_test', + 'email' => 'tenantuser@example.com', + 'password' => bcrypt('password'), + 'company_id' => $this->company->id, + ]); + // Give them a role that has analysis permissions + $role = \App\Models\System\Role::create([ + 'name' => '測試角色', + 'company_id' => $this->company->id, + 'guard_name' => 'web', + 'is_system' => false, + ]); + $role->syncPermissions([ + 'menu.analysis', + 'menu.analysis.machine-reports', + 'menu.analysis.product-reports' + ]); + $this->tenantUser->assignRole($role); + + // 5. Create Machines + $this->machineA = Machine::create([ + 'company_id' => $this->company->id, + 'name' => 'Machine Alpha', + 'serial_no' => 'MC0000A', + 'status' => 'online', + ]); + + $this->machineB = Machine::create([ + 'company_id' => $this->company->id, + 'name' => 'Machine Beta', + 'serial_no' => 'MC0000B', + 'status' => 'online', + ]); + + // Authorize Tenant User to ONLY see Machine A + $this->machineA->users()->attach($this->tenantUser->id); + + // 6. Create Products + $this->productX = Product::create([ + 'company_id' => $this->company->id, + 'name' => 'Cola', + 'barcode' => 'BAR0001', + 'cost' => 10.00, + 'price' => 15.00, + 'is_active' => true, + ]); + + $this->productY = Product::create([ + 'company_id' => $this->company->id, + 'name' => 'Soda', + 'barcode' => 'BAR0002', + 'cost' => 20.00, + 'price' => 25.00, + 'is_active' => true, + ]); + + // 7. Seed Orders + // Order 1: Machine A, Product X, payment type 1 (Credit Card), price 15, quantity 2. (Sales: 30, Cost: 20, Profit: 10) + $order1 = Order::create([ + 'company_id' => $this->company->id, + 'order_no' => 'ORD0001', + 'machine_id' => $this->machineA->id, + 'payment_type' => 1, + 'total_amount' => 30.00, + 'pay_amount' => 30.00, + 'payment_status' => Order::PAYMENT_STATUS_SUCCESS, + 'payment_at' => now(), + ]); + OrderItem::create([ + 'order_id' => $order1->id, + 'product_id' => $this->productX->id, + 'product_name' => $this->productX->name, + 'barcode' => $this->productX->barcode, + 'quantity' => 2, + 'price' => 15.00, + 'subtotal' => 30.00, + ]); + + // Order 2: Machine B, Product Y, payment type 2 (E-ticket), price 25, quantity 1. (Sales: 25, Cost: 20, Profit: 5) + $order2 = Order::create([ + 'company_id' => $this->company->id, + 'order_no' => 'ORD0002', + 'machine_id' => $this->machineB->id, + 'payment_type' => 2, + 'total_amount' => 25.00, + 'pay_amount' => 25.00, + 'payment_status' => Order::PAYMENT_STATUS_SUCCESS, + 'payment_at' => now(), + ]); + OrderItem::create([ + 'order_id' => $order2->id, + 'product_id' => $this->productY->id, + 'product_name' => $this->productY->name, + 'barcode' => $this->productY->barcode, + 'quantity' => 1, + 'price' => 25.00, + 'subtotal' => 25.00, + ]); + } + + /** + * 測試超級管理員可以撈出所有機台的銷售統計 + */ + public function test_super_admin_gets_all_machines_statistics() + { + $this->actingAs($this->superAdmin); + + $response = $this->getJson(route('admin.analysis.machine-reports', [ + 'company_id' => $this->company->id + ])); + + $response->assertStatus(200) + ->assertJsonPath('success', true); + + $data = $response->json(); + + // 總和驗證 + $this->assertEquals(3, $data['summary']['total_quantity']); + $this->assertEquals(55.0, $data['summary']['total_sales']); + $this->assertEquals(40.0, $data['summary']['total_cost']); + $this->assertEquals(15.0, $data['summary']['total_profit']); + + // 機台明細驗證 + $this->assertCount(2, $data['table_data']); + + $machineAData = collect($data['table_data'])->firstWhere('machine_id', $this->machineA->id); + $this->assertNotNull($machineAData); + $this->assertEquals(2, $machineAData['total_quantity']); + $this->assertEquals(30.0, $machineAData['total_sales']); + + $machineBData = collect($data['table_data'])->firstWhere('machine_id', $this->machineB->id); + $this->assertNotNull($machineBData); + $this->assertEquals(1, $machineBData['total_quantity']); + $this->assertEquals(25.0, $machineBData['total_sales']); + } + + /** + * 測試一般租戶使用者只能撈出有被授權機台的數據 (Machine A),無法看到 Machine B + */ + public function test_tenant_user_only_sees_authorized_machine_statistics() + { + $this->actingAs($this->tenantUser); + + $response = $this->getJson(route('admin.analysis.machine-reports')); + + $response->assertStatus(200) + ->assertJsonPath('success', true); + + $data = $response->json(); + + // 總和應只包含 Machine A + $this->assertEquals(2, $data['summary']['total_quantity']); + $this->assertEquals(30.0, $data['summary']['total_sales']); + $this->assertEquals(20.0, $data['summary']['total_cost']); + $this->assertEquals(10.0, $data['summary']['total_profit']); + + // 機台明細應只有 1 筆 (Machine A) + $this->assertCount(1, $data['table_data']); + $this->assertEquals($this->machineA->id, $data['table_data'][0]['machine_id']); + } + + /** + * 測試支付工具篩選器能正確過濾資料 + */ + public function test_payment_types_filter_works() + { + $this->actingAs($this->superAdmin); + + // 僅篩選支付類型 1 (信用卡) + $response = $this->getJson(route('admin.analysis.machine-reports', [ + 'company_id' => $this->company->id, + 'payment_types' => '1' + ])); + + $response->assertStatus(200); + $data = $response->json(); + + // 應只有 Order 1 的資料 (Machine A) + $this->assertEquals(2, $data['summary']['total_quantity']); + $this->assertEquals(30.0, $data['summary']['total_sales']); + $this->assertCount(1, $data['table_data']); + $this->assertEquals($this->machineA->id, $data['table_data'][0]['machine_id']); + } + + /** + * 測試商品報表中的機台過濾功能 + */ + public function test_product_reports_machine_filter_works() + { + $this->actingAs($this->superAdmin); + + // 篩選 Machine B + $response = $this->getJson(route('admin.analysis.product-reports', [ + 'company_id' => $this->company->id, + 'machine_id' => $this->machineB->id + ])); + + $response->assertStatus(200); + $data = $response->json(); + + // Machine B 上面只有 Product Y (Soda) + $this->assertEquals(1, $data['summary']['total_quantity']); + $this->assertEquals(25.0, $data['summary']['total_sales']); + $this->assertCount(1, $data['table_data']); + $this->assertEquals($this->productY->id, $data['table_data'][0]['product_id']); + } +}