[PROMOTE] 將 dev 分支晉升至 demo

1. 實作機台報表分析功能與商品報表篩選列優化,包含多租戶資料隔離與帳號授權限制。
2. 於機台庫存概覽與庫存效期管理頁面中加顯示商品 ID,覆蓋 Table View、Grid View、Mobile Card View 以及調整庫存彈窗。
This commit is contained in:
sky121113 2026-06-05 16:43:24 +08:00
commit 5673856ca3
12 changed files with 1818 additions and 33 deletions

View File

@ -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,

View File

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

View File

@ -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',

View File

@ -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",

View File

@ -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": "期限間近",

View File

@ -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": "警告",

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
@endsection
@section('content')
<div class="space-y-6 pb-20" x-data="productReportApp(@js($summary), @js($chartData), @js($tableData), '{{ $dateRange }}', '{{ $selectedProductId }}', '{{ $selectedCompanyId }}')">
<div class="space-y-6 pb-20" x-data="productReportApp(@js($summary), @js($chartData), @js($tableData), '{{ $dateRange }}', '{{ $selectedProductId }}', '{{ $selectedCompanyId }}', '{{ $selectedMachineId }}')">
<!-- Page Header -->
<x-page-header
@ -108,7 +108,7 @@
<!-- Filter Panel Card -->
<div class="luxury-card rounded-3xl p-4 sm:p-5 relative animate-luxury-in z-[40]" style="animation-delay: 250ms">
<div class="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
<div class="flex flex-col xl:flex-row xl:flex-wrap xl:items-center justify-between gap-4">
<!-- Date Shortcut Button Bar -->
<div class="flex flex-wrap items-center gap-1.5 shrink-0">
@ -123,7 +123,7 @@
</div>
<!-- Filter Controls Form -->
<div class="flex flex-col sm:flex-row sm:items-center gap-3 w-full xl:w-auto xl:flex-1 xl:justify-end relative">
<div class="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-3 w-full xl:w-auto xl:flex-1 xl:justify-end relative">
<!-- Date Range (Flatpickr) -->
<div class="w-full sm:w-80 relative focus-within:z-[50]">
<div class="relative group">
@ -138,7 +138,7 @@
<!-- Company Selection (Super Admin only) -->
@if(auth()->user()->isSystemAdmin())
<div class="w-full sm:w-48 relative focus-within:z-[30]">
<div class="w-full sm:w-52 relative focus-within:z-[30]">
<x-searchable-select name="company_id" id="company_id" :placeholder="__('All Companies')" :selected="$selectedCompanyId"
@change="companyChanged($event.target.value)">
@foreach($companies as $c)
@ -148,8 +148,20 @@
</div>
@endif
<!-- Machine Selection -->
<div class="w-full sm:w-52 relative focus-within:z-[30]">
<x-searchable-select name="machine_id" id="machine_id" :placeholder="__('All Machines')" :selected="$selectedMachineId"
@change="selectedMachineId = ($event.target.value && $event.target.value.trim() !== '') ? $event.target.value.trim() : 'ALL'; fetchData()">
@foreach($machines as $m)
<option value="{{ $m->id }}" {{ (string)$selectedMachineId === (string)$m->id ? 'selected' : '' }} data-title="{{ $m->name }}{{ $m->serial_no ? ' (' . $m->serial_no . ')' : '' }}">
{{ $m->name }}{{ $m->serial_no ? ' (' . $m->serial_no . ')' : '' }}
</option>
@endforeach
</x-searchable-select>
</div>
<!-- Product Selection -->
<div class="w-full sm:w-48 relative focus-within:z-[30]">
<div class="w-full sm:w-52 relative focus-within:z-[30]">
<x-searchable-select name="product_id" id="product_id" :placeholder="__('All Products (ALL)')" :selected="$selectedProductId"
@change="selectedProductId = ($event.target.value && $event.target.value.trim() !== '') ? $event.target.value.trim() : 'ALL'; fetchData()">
@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,

View File

@ -695,8 +695,13 @@
<!-- Slot Info -->
<div class="text-center w-full space-y-3">
<template x-if="slot.product">
<div class="text-base font-black truncate w-full opacity-90 tracking-tight"
x-text="slot.product.name"></div>
<div>
<div class="text-base font-black truncate w-full opacity-90 tracking-tight"
x-text="slot.product.name"></div>
<template x-if="slot.product.id">
<div class="text-[10px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-1">ID: <span x-text="slot.product.id"></span></div>
</template>
</div>
</template>
<div class="space-y-3">
@ -771,8 +776,16 @@
<div>
<div class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></div>
<div class="text-sm font-bold text-slate-400 uppercase tracking-widest mt-1"
x-text="slot.expiry_date ? '{{ __('Expiry') }}: ' + slot.expiry_date : ''">
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1 mt-1">
<template x-if="slot.product?.id">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500 font-mono tracking-widest uppercase">ID: <span x-text="slot.product.id"></span></span>
</template>
<template x-if="slot.product?.id && slot.expiry_date">
<span class="text-xs text-slate-300 dark:text-slate-700">|</span>
</template>
<span class="text-sm font-bold text-slate-400 uppercase tracking-widest"
x-text="slot.expiry_date ? '{{ __('Expiry') }}: ' + slot.expiry_date : ''">
</span>
</div>
</div>
</div>
@ -823,7 +836,13 @@
<span class="px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-[10px] font-black text-slate-500 dark:text-slate-400 tracking-tighter" x-text="slot.slot_no"></span>
<h3 class="text-base font-black text-slate-800 dark:text-slate-100 truncate group-hover:text-cyan-600 transition-colors tracking-tight" x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></h3>
</div>
<p class="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-widest truncate mt-1" x-text="slot.product?.barcode || '--'"></p>
<div class="flex flex-wrap items-center gap-x-2 mt-1">
<template x-if="slot.product?.id">
<span class="text-[10px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">ID: <span x-text="slot.product.id"></span></span>
</template>
<span class="text-[10px] text-slate-300 dark:text-slate-700">|</span>
<span class="text-[10px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate" x-text="slot.product?.barcode || '--'"></span>
</div>
</div>
</div>
<div class="shrink-0 text-right">
@ -886,8 +905,11 @@
class="text-cyan-500"></span>
</h3>
<template x-if="selectedSlot && selectedSlot.product">
<p x-text="selectedSlot?.product?.name"
class="text-base font-black text-slate-400 uppercase tracking-widest mt-3 ml-0.5">
<p class="text-base font-black text-slate-400 uppercase tracking-widest mt-3 ml-0.5">
<span x-text="selectedSlot?.product?.name"></span>
<template x-if="selectedSlot?.product?.id">
<span class="font-mono text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest ml-1.5">(ID: <span x-text="selectedSlot.product.id"></span>)</span>
</template>
</p>
</template>
</div>

View File

@ -309,6 +309,9 @@
<div class="text-center w-full">
<div class="text-base font-black truncate w-full opacity-90 tracking-tight"
x-text="slot.product?.name || '{{ __('Empty') }}'"></div>
<template x-if="slot.product?.id">
<div class="text-[10px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-1">ID: <span x-text="slot.product.id"></span></div>
</template>
<div class="flex items-baseline justify-center gap-1 mt-2">
<span class="text-2xl font-black tracking-tighter" x-text="slot.stock"></span>
<span class="text-sm font-black opacity-30">/</span>
@ -363,8 +366,16 @@
<div>
<div class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></div>
<div class="text-sm font-bold text-slate-400 uppercase tracking-widest mt-1"
x-text="slot.expiry_date ? '{{ __('Expiry') }}: ' + slot.expiry_date : ''">
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1 mt-1">
<template x-if="slot.product?.id">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500 font-mono tracking-widest uppercase">ID: <span x-text="slot.product.id"></span></span>
</template>
<template x-if="slot.product?.id && slot.expiry_date">
<span class="text-xs text-slate-300 dark:text-slate-700">|</span>
</template>
<span class="text-sm font-bold text-slate-400 uppercase tracking-widest"
x-text="slot.expiry_date ? '{{ __('Expiry') }}: ' + slot.expiry_date : ''">
</span>
</div>
</div>
</div>
@ -413,7 +424,13 @@
<span class="px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-[10px] font-black text-slate-500 dark:text-slate-400 tracking-tighter" x-text="slot.slot_no"></span>
<h3 class="text-base font-black text-slate-800 dark:text-slate-100 truncate group-hover:text-cyan-600 transition-colors tracking-tight" x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></h3>
</div>
<p class="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-widest truncate mt-1" x-text="slot.product?.barcode || '--'"></p>
<div class="flex flex-wrap items-center gap-x-2 mt-1">
<template x-if="slot.product?.id">
<span class="text-[10px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">ID: <span x-text="slot.product.id"></span></span>
</template>
<span class="text-[10px] text-slate-300 dark:text-slate-700">|</span>
<span class="text-[10px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate" x-text="slot.product?.barcode || '--'"></span>
</div>
</div>
</div>
<div class="shrink-0 text-right">

View File

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

View File

@ -0,0 +1,274 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\System\Company;
use App\Models\System\User;
use App\Models\Machine\Machine;
use App\Models\Product\Product;
use App\Models\Transaction\Order;
use App\Models\Transaction\OrderItem;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AnalysisReportsTest extends TestCase
{
use RefreshDatabase;
protected $company;
protected $superAdmin;
protected $tenantUser;
protected $machineA;
protected $machineB;
protected $productX;
protected $productY;
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}