diff --git a/.gitea/workflows/deploy-prod.yaml b/.gitea/workflows/deploy-prod.yaml index 97aaa36..262200b 100644 --- a/.gitea/workflows/deploy-prod.yaml +++ b/.gitea/workflows/deploy-prod.yaml @@ -31,21 +31,9 @@ jobs: docker run --rm -v /home/twsystem1003/star-cloud:/target busybox mkdir -p /target/docker/emqx cat docker/emqx/acl.conf | docker run -i --rm -v /home/twsystem1003/star-cloud:/target busybox sh -c "cat > /target/docker/emqx/acl.conf" - - name: Step 1 - Ensure Existing Containers Are Running + - name: Step 1 - Build & Deploy Containers run: | - for container in \ - star-cloud-mysql \ - star-cloud-redis \ - star-cloud-emqx - do - if ! docker inspect "${container}" >/dev/null 2>&1; then - echo ">>> Required container is missing: ${container}" - exit 1 - fi - - echo ">>> Ensuring ${container} is running..." - docker start "${container}" >/dev/null - done + WWWGROUP=1001 WWWUSER=1001 docker-compose up -d --build for container in star-cloud-mysql star-cloud-redis star-cloud-emqx; do echo ">>> Waiting for ${container} to become healthy..." @@ -68,21 +56,6 @@ jobs: done done - for container in \ - star-cloud-laravel \ - star-cloud-queue \ - star-cloud-mqtt-worker \ - star-cloud-mqtt-gateway - do - if ! docker inspect "${container}" >/dev/null 2>&1; then - echo ">>> Required container is missing: ${container}" - exit 1 - fi - - echo ">>> Ensuring ${container} is running..." - docker start "${container}" >/dev/null - done - - name: Step 2 - Deploy Code run: | echo ">>> Syncing code to star-cloud-laravel..." diff --git a/app/Http/Controllers/Admin/AnalysisController.php b/app/Http/Controllers/Admin/AnalysisController.php index 4357105..9bd1ee0 100644 --- a/app/Http/Controllers/Admin/AnalysisController.php +++ b/app/Http/Controllers/Admin/AnalysisController.php @@ -99,56 +99,129 @@ class AnalysisController extends Controller 42 => __('Pickup Voucher'), ]; + // 子查詢:取得每個 order_id + product_id 的銷售單價 + $itemPriceQuery = \Illuminate\Support\Facades\DB::table('order_items') + ->select([ + 'order_id', + 'product_id', + \Illuminate\Support\Facades\DB::raw('MIN(price) as unit_price') + ]) + ->groupBy('order_id', 'product_id'); + // 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') + // 子查詢 A:營業額 (計算機台實收) + $salesQuery = \Illuminate\Support\Facades\DB::table('orders as o') ->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') + \Illuminate\Support\Facades\DB::raw('SUM(o.pay_amount) as total_sales') ]) ->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); + $salesQuery->where('o.company_id', $effectiveCompanyId); + } + if ($allowedMachineIds !== null) { + if (empty($allowedMachineIds)) { + $salesQuery->whereRaw('1 = 0'); + } else { + $salesQuery->whereIn('o.machine_id', $allowedMachineIds); + } + } + if ($selectedMachineId && $selectedMachineId !== 'ALL') { + $salesQuery->where('o.machine_id', $selectedMachineId); + } + if (!empty($paymentTypes)) { + $salesQuery->whereIn('o.payment_type', $paymentTypes); } - // 套用機台權限限制 + // 如果篩選特定商品,營業額只計算有成功出貨的該商品金額 + if ($selectedProductId && $selectedProductId !== 'ALL') { + $salesQuery->join('dispense_records as dr', function($join) { + $join->on('dr.order_id', '=', 'o.id') + ->where('dr.dispense_status', '=', 1); + }) + ->leftJoinSub($itemPriceQuery, 'oi', function($join) { + $join->on('oi.order_id', '=', 'dr.order_id') + ->on('oi.product_id', '=', 'dr.product_id'); + }) + ->where('dr.product_id', $selectedProductId) + ->select([ + 'o.machine_id', + \Illuminate\Support\Facades\DB::raw('SUM(COALESCE(oi.unit_price, 0)) as total_sales') + ]); + } + $salesQuery->groupBy('o.machine_id'); + + // 子查詢 B:實際出貨件數與成本 (僅統計出貨成功且在範圍內) + $dispenseQuery = \Illuminate\Support\Facades\DB::table('dispense_records as dr') + ->join('orders as o', 'o.id', '=', 'dr.order_id') + ->leftJoin('products as p', 'p.id', '=', 'dr.product_id') + ->select([ + 'dr.machine_id', + \Illuminate\Support\Facades\DB::raw('COUNT(dr.id) as total_quantity'), + \Illuminate\Support\Facades\DB::raw('SUM(COALESCE(p.cost, 0)) as total_cost') + ]) + ->where('dr.dispense_status', 1) + ->whereNull('o.deleted_at') + ->whereBetween('o.created_at', [$startDate, $endDate]); + + // 套用篩選條件到出貨子查詢 + if ($effectiveCompanyId) { + $dispenseQuery->where('o.company_id', $effectiveCompanyId); + } + if ($allowedMachineIds !== null) { + if (empty($allowedMachineIds)) { + $dispenseQuery->whereRaw('1 = 0'); + } else { + $dispenseQuery->whereIn('o.machine_id', $allowedMachineIds); + } + } + if ($selectedMachineId && $selectedMachineId !== 'ALL') { + $dispenseQuery->where('o.machine_id', $selectedMachineId); + } + if ($selectedProductId && $selectedProductId !== 'ALL') { + $dispenseQuery->where('dr.product_id', $selectedProductId); + } + if (!empty($paymentTypes)) { + $dispenseQuery->whereIn('o.payment_type', $paymentTypes); + } + $dispenseQuery->groupBy('dr.machine_id'); + + // 聯結機台與子查詢,取得最終統計資料 + $mainQuery = \Illuminate\Support\Facades\DB::table('machines as m') + ->leftJoinSub($salesQuery, 'sq', 'sq.machine_id', '=', 'm.id') + ->leftJoinSub($dispenseQuery, 'dq', 'dq.machine_id', '=', 'm.id') + ->select([ + 'm.id as 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('CAST(COALESCE(dq.total_quantity, 0) as SIGNED) as total_quantity'), + \Illuminate\Support\Facades\DB::raw('CAST(COALESCE(sq.total_sales, 0) as DECIMAL(10,2)) as total_sales'), + \Illuminate\Support\Facades\DB::raw('CAST(COALESCE(dq.total_cost, 0) as DECIMAL(10,2)) as total_cost'), + \Illuminate\Support\Facades\DB::raw('CAST((COALESCE(sq.total_sales, 0) - COALESCE(dq.total_cost, 0)) as DECIMAL(10,2)) as total_profit') + ]) + ->where(function($query) { + $query->whereNotNull('sq.total_sales') + ->orWhereNotNull('dq.total_quantity'); + }); + + if ($effectiveCompanyId) { + $mainQuery->where('m.company_id', $effectiveCompanyId); + } if ($allowedMachineIds !== null) { if (empty($allowedMachineIds)) { $mainQuery->whereRaw('1 = 0'); } else { - $mainQuery->whereIn('o.machine_id', $allowedMachineIds); + $mainQuery->whereIn('m.id', $allowedMachineIds); } } - - // 套用特定機台篩選 if ($selectedMachineId && $selectedMachineId !== 'ALL') { - $mainQuery->where('o.machine_id', $selectedMachineId); + $mainQuery->where('m.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 @@ -176,46 +249,103 @@ class AnalysisController extends Controller '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') + // 8. 圖表日走勢統計 + // 每日營業額 + $trendSalesQuery = \Illuminate\Support\Facades\DB::table('orders as o') ->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'), + \Illuminate\Support\Facades\DB::raw('SUM(o.pay_amount) as total_sales') ]) ->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); + $trendSalesQuery->where('o.company_id', $effectiveCompanyId); } - if ($allowedMachineIds !== null) { if (empty($allowedMachineIds)) { - $trendQuery->whereRaw('1 = 0'); + $trendSalesQuery->whereRaw('1 = 0'); } else { - $trendQuery->whereIn('o.machine_id', $allowedMachineIds); + $trendSalesQuery->whereIn('o.machine_id', $allowedMachineIds); } } - if ($selectedMachineId && $selectedMachineId !== 'ALL') { - $trendQuery->where('o.machine_id', $selectedMachineId); + $trendSalesQuery->where('o.machine_id', $selectedMachineId); } - - if ($selectedProductId && $selectedProductId !== 'ALL') { - $trendQuery->where('oi.product_id', $selectedProductId); - } - if (!empty($paymentTypes)) { - $trendQuery->whereIn('o.payment_type', $paymentTypes); + $trendSalesQuery->whereIn('o.payment_type', $paymentTypes); } - $trendQuery->groupBy('date')->orderBy('date', 'asc'); - $trendData = $trendQuery->get(); + // 如果篩選特定商品,營業額只計算有成功出貨的商品價格 + if ($selectedProductId && $selectedProductId !== 'ALL') { + $trendSalesQuery->join('dispense_records as dr', function($join) { + $join->on('dr.order_id', '=', 'o.id') + ->where('dr.dispense_status', '=', 1); + }) + ->leftJoinSub($itemPriceQuery, 'oi', function($join) { + $join->on('oi.order_id', '=', 'dr.order_id') + ->on('oi.product_id', '=', 'dr.product_id'); + }) + ->where('dr.product_id', $selectedProductId) + ->select([ + \Illuminate\Support\Facades\DB::raw("DATE_FORMAT(o.created_at, '%Y-%m-%d') as date"), + \Illuminate\Support\Facades\DB::raw('SUM(COALESCE(oi.unit_price, 0)) as total_sales') + ]); + } + $trendSalesQuery->groupBy('date'); + $trendSalesData = $trendSalesQuery->get(); + + // 每日出貨件數與成本 + $trendDispenseQuery = \Illuminate\Support\Facades\DB::table('dispense_records as dr') + ->join('orders as o', 'o.id', '=', 'dr.order_id') + ->leftJoin('products as p', 'p.id', '=', 'dr.product_id') + ->select([ + \Illuminate\Support\Facades\DB::raw("DATE_FORMAT(o.created_at, '%Y-%m-%d') as date"), + \Illuminate\Support\Facades\DB::raw('COUNT(dr.id) as total_quantity'), + \Illuminate\Support\Facades\DB::raw('SUM(COALESCE(p.cost, 0)) as total_cost') + ]) + ->where('dr.dispense_status', 1) + ->whereNull('o.deleted_at') + ->whereBetween('o.created_at', [$startDate, $endDate]); + + // 套用篩選到走勢出貨 + if ($effectiveCompanyId) { + $trendDispenseQuery->where('o.company_id', $effectiveCompanyId); + } + if ($allowedMachineIds !== null) { + if (empty($allowedMachineIds)) { + $trendDispenseQuery->whereRaw('1 = 0'); + } else { + $trendDispenseQuery->whereIn('o.machine_id', $allowedMachineIds); + } + } + if ($selectedMachineId && $selectedMachineId !== 'ALL') { + $trendDispenseQuery->where('o.machine_id', $selectedMachineId); + } + if ($selectedProductId && $selectedProductId !== 'ALL') { + $trendDispenseQuery->where('dr.product_id', $selectedProductId); + } + if (!empty($paymentTypes)) { + $trendDispenseQuery->whereIn('o.payment_type', $paymentTypes); + } + $trendDispenseQuery->groupBy('date'); + $trendDispenseData = $trendDispenseQuery->get(); + + // 將子查詢結果轉換為 PHP Map 對照 + $salesMap = []; + foreach ($trendSalesData as $row) { + $salesMap[$row->date] = $row->total_sales; + } + + $dispenseMap = []; + foreach ($trendDispenseData as $row) { + $dispenseMap[$row->date] = [ + 'quantity' => $row->total_quantity, + 'cost' => $row->total_cost + ]; + } // 補齊日期區間內所有空缺日走勢 $period = new \DatePeriod( @@ -227,21 +357,16 @@ class AnalysisController extends Controller $chartData = []; foreach ($period as $date) { $dateStr = $date->format('Y-m-d'); + $hasSales = isset($salesMap[$dateStr]); + $hasDispense = isset($dispenseMap[$dateStr]); + $chartData[$dateStr] = [ 'date' => $dateStr, - 'quantity' => 0, - 'sales' => 0.0, - 'cost' => 0.0, + 'quantity' => $hasDispense ? (int)$dispenseMap[$dateStr]['quantity'] : 0, + 'sales' => $hasSales ? round((float)$salesMap[$dateStr], 2) : 0.0, + 'cost' => $hasDispense ? round((float)$dispenseMap[$dateStr]['cost'], 2) : 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 格式 @@ -337,19 +462,35 @@ class AnalysisController extends Controller $products = $productsQuery->active()->get(['id', 'name', 'barcode']); // 5. 執行主報表統計 SQL - // 計算各商品的銷售數量、銷售額、總成本、利潤 - $mainQuery = \Illuminate\Support\Facades\DB::table('order_items as oi') - ->join('orders as o', 'o.id', '=', 'oi.order_id') - ->leftJoin('products as p', 'p.id', '=', 'oi.product_id') + // 子查詢:取得每個 order_id + product_id 的銷售單價與品名 + $itemPriceQuery = \Illuminate\Support\Facades\DB::table('order_items') ->select([ - 'oi.product_id', + 'order_id', + 'product_id', + \Illuminate\Support\Facades\DB::raw('MIN(price) as unit_price'), + \Illuminate\Support\Facades\DB::raw('MIN(barcode) as barcode'), + \Illuminate\Support\Facades\DB::raw('MIN(product_name) as product_name') + ]) + ->groupBy('order_id', 'product_id'); + + // 計算各商品的銷售數量、銷售額、總成本、利潤 (以出貨成功為主體) + $mainQuery = \Illuminate\Support\Facades\DB::table('dispense_records as dr') + ->join('orders as o', 'o.id', '=', 'dr.order_id') + ->leftJoinSub($itemPriceQuery, 'oi', function($join) { + $join->on('oi.order_id', '=', 'dr.order_id') + ->on('oi.product_id', '=', 'dr.product_id'); + }) + ->leftJoin('products as p', 'p.id', '=', 'dr.product_id') + ->select([ + 'dr.product_id', \Illuminate\Support\Facades\DB::raw('COALESCE(p.barcode, oi.barcode) as barcode'), \Illuminate\Support\Facades\DB::raw('COALESCE(p.name, oi.product_name) as name'), - \Illuminate\Support\Facades\DB::raw('SUM(oi.quantity) as total_quantity'), - \Illuminate\Support\Facades\DB::raw('SUM(oi.subtotal) as total_sales'), - \Illuminate\Support\Facades\DB::raw('SUM(oi.quantity * COALESCE(p.cost, 0)) as total_cost'), - \Illuminate\Support\Facades\DB::raw('SUM(oi.subtotal) - SUM(oi.quantity * COALESCE(p.cost, 0)) as total_profit') + \Illuminate\Support\Facades\DB::raw('CAST(COUNT(dr.id) as SIGNED) as total_quantity'), + \Illuminate\Support\Facades\DB::raw('CAST(SUM(COALESCE(oi.unit_price, 0)) as DECIMAL(10,2)) as total_sales'), + \Illuminate\Support\Facades\DB::raw('CAST(SUM(COALESCE(p.cost, 0)) as DECIMAL(10,2)) as total_cost'), + \Illuminate\Support\Facades\DB::raw('CAST((SUM(COALESCE(oi.unit_price, 0)) - SUM(COALESCE(p.cost, 0))) as DECIMAL(10,2)) as total_profit') ]) + ->where('dr.dispense_status', 1) ->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS) ->whereNull('o.deleted_at') ->whereBetween('o.created_at', [$startDate, $endDate]); @@ -375,10 +516,10 @@ class AnalysisController extends Controller // 套用特定商品篩選 if ($selectedProductId && $selectedProductId !== 'ALL') { - $mainQuery->where('oi.product_id', $selectedProductId); + $mainQuery->where('dr.product_id', $selectedProductId); } - $mainQuery->groupBy('oi.product_id') + $mainQuery->groupBy('dr.product_id') ->groupBy(\Illuminate\Support\Facades\DB::raw('COALESCE(p.barcode, oi.barcode)')) ->groupBy(\Illuminate\Support\Facades\DB::raw('COALESCE(p.name, oi.product_name)')); $tableData = $mainQuery->get(); @@ -409,15 +550,20 @@ class AnalysisController extends Controller ]; // 7. 圖表日走勢統計 SQL - $trendQuery = \Illuminate\Support\Facades\DB::table('order_items as oi') - ->join('orders as o', 'o.id', '=', 'oi.order_id') - ->leftJoin('products as p', 'p.id', '=', 'oi.product_id') + $trendQuery = \Illuminate\Support\Facades\DB::table('dispense_records as dr') + ->join('orders as o', 'o.id', '=', 'dr.order_id') + ->leftJoinSub($itemPriceQuery, 'oi', function($join) { + $join->on('oi.order_id', '=', 'dr.order_id') + ->on('oi.product_id', '=', 'dr.product_id'); + }) + ->leftJoin('products as p', 'p.id', '=', 'dr.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'), + \Illuminate\Support\Facades\DB::raw('CAST(COUNT(dr.id) as SIGNED) as total_quantity'), + \Illuminate\Support\Facades\DB::raw('CAST(SUM(COALESCE(oi.unit_price, 0)) as DECIMAL(10,2)) as total_sales'), + \Illuminate\Support\Facades\DB::raw('CAST(SUM(COALESCE(p.cost, 0)) as DECIMAL(10,2)) as total_cost') ]) + ->where('dr.dispense_status', 1) ->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS) ->whereNull('o.deleted_at') ->whereBetween('o.created_at', [$startDate, $endDate]); @@ -439,7 +585,7 @@ class AnalysisController extends Controller } if ($selectedProductId && $selectedProductId !== 'ALL') { - $trendQuery->where('oi.product_id', $selectedProductId); + $trendQuery->where('dr.product_id', $selectedProductId); } $trendQuery->groupBy('date')->orderBy('date', 'asc'); diff --git a/tests/Feature/Admin/AnalysisReportsTest.php b/tests/Feature/Admin/AnalysisReportsTest.php index c623055..b925bea 100644 --- a/tests/Feature/Admin/AnalysisReportsTest.php +++ b/tests/Feature/Admin/AnalysisReportsTest.php @@ -8,6 +8,7 @@ use App\Models\Machine\Machine; use App\Models\Product\Product; use App\Models\Transaction\Order; use App\Models\Transaction\OrderItem; +use App\Models\Transaction\DispenseRecord; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -143,6 +144,27 @@ class AnalysisReportsTest extends TestCase 'subtotal' => 30.00, ]); + DispenseRecord::create([ + 'company_id' => $this->company->id, + 'order_id' => $order1->id, + 'machine_id' => $this->machineA->id, + 'slot_no' => '1', + 'product_id' => $this->productX->id, + 'amount' => 15.00, + 'dispense_status' => 1, + 'machine_time' => now(), + ]); + DispenseRecord::create([ + 'company_id' => $this->company->id, + 'order_id' => $order1->id, + 'machine_id' => $this->machineA->id, + 'slot_no' => '1', + 'product_id' => $this->productX->id, + 'amount' => 15.00, + 'dispense_status' => 1, + 'machine_time' => now(), + ]); + // 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, @@ -163,6 +185,17 @@ class AnalysisReportsTest extends TestCase 'price' => 25.00, 'subtotal' => 25.00, ]); + + DispenseRecord::create([ + 'company_id' => $this->company->id, + 'order_id' => $order2->id, + 'machine_id' => $this->machineB->id, + 'slot_no' => '1', + 'product_id' => $this->productY->id, + 'amount' => 25.00, + 'dispense_status' => 1, + 'machine_time' => now(), + ]); } /**