[PROMOTE] 將 dev 分支晉升至 demo
1. 實作機台報表分析功能與商品報表篩選列優化,包含多租戶資料隔離與帳號授權限制。 2. 於機台庫存概覽與庫存效期管理頁面中加顯示商品 ID,覆蓋 Table View、Grid View、Mobile Card View 以及調整庫存彈窗。
This commit is contained in:
commit
5673856ca3
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
10
lang/en.json
10
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",
|
||||
|
||||
10
lang/ja.json
10
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": "期限間近",
|
||||
|
||||
@ -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": "警告",
|
||||
|
||||
1144
resources/views/admin/analysis/machine-reports.blade.php
Normal file
1144
resources/views/admin/analysis/machine-reports.blade.php
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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',
|
||||
];
|
||||
|
||||
|
||||
274
tests/Feature/Admin/AnalysisReportsTest.php
Normal file
274
tests/Feature/Admin/AnalysisReportsTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user