value => 'pending', OrderStatus::ACTIVE->value => 'active', OrderStatus::CANCELLED->value => 'cancelled', OrderStatus::PROCESSING->value => 'processing', default => (string) $status, }; } /** Map Invoice status int → FE payment status (paid/pending/failed) */ private function paymentStatusFromInvoice(?int $status): string { return match ($status) { InvoiceStatus::PAID->value => 'paid', InvoiceStatus::UNPAID->value => 'pending', InvoiceStatus::CANCELLED->value => 'failed', default => 'pending', }; } /** % delta giữa current vs previous (an toàn chia 0) */ private function deltaPct(float $current, float $previous): float { if ($previous == 0.0) { return $current > 0 ? 100.0 : 0.0; } return round((($current - $previous) / $previous) * 100, 2); } /** * @return array{0: Carbon, 1: Carbon, 2: Carbon, 3: Carbon} */ private function periodFromRequest(Request $request): array { $request->validate([ 'from' => ['nullable', 'date_format:Y-m-d', 'required_with:to'], 'to' => ['nullable', 'date_format:Y-m-d', 'required_with:from', 'after_or_equal:from'], ]); $start = $request->date('from', 'Y-m-d')?->startOfDay() ?? now()->startOfMonth(); $end = $request->date('to', 'Y-m-d')?->endOfDay() ?? now(); $days = (int) $start->diffInDays($end->copy()->startOfDay()) + 1; return [$start, $end, $start->copy()->subDays($days), $start->copy()->subSecond()]; } public function stats(Request $request): JsonResponse { $now = now(); [$start, $end, $prevStart, $prevEnd] = $this->periodFromRequest($request); // Customers $totalCustomers = Customer::count(); $newInPeriod = Customer::whereBetween('created_at', [$start, $end])->count(); $newPrevPeriod = Customer::whereBetween('created_at', [$prevStart, $prevEnd])->count(); // Deposits (transactions completed, type ∈ DEPOSIT_TYPES) $depositBase = Transaction::whereIn('type', self::DEPOSIT_TYPES) ->where('status', TransactionStatus::COMPLETED->value); $totalDeposits = (float) (clone $depositBase)->sum('amount'); $depositsInPeriod = (float) (clone $depositBase) ->whereBetween('created_at', [$start, $end]) ->sum('amount'); $depositsPrevPeriod = (float) (clone $depositBase) ->whereBetween('created_at', [$prevStart, $prevEnd]) ->sum('amount'); // Số dư ví khách hàng (cột customers.total) $totalBalance = (float) Customer::sum('total'); // Services $totalActiveServices = Service::where('status', 'active')->count(); $expiring7d = Service::where('status', 'active') ->whereNotNull('expires_at') ->whereBetween('expires_at', [$now, $now->copy()->addDays(7)]) ->count(); // Orders $ordersInPeriod = Order::whereBetween('created_at', [$start, $end])->count(); $ordersPrevPeriod = Order::whereBetween('created_at', [$prevStart, $prevEnd])->count(); $pendingOrders = Order::where('status', OrderStatus::PENDING->value)->count(); return response()->json([ 'error' => false, 'data' => [ 'total_customers' => $totalCustomers, 'new_customers_in_period' => $newInPeriod, 'new_customers_delta_pct' => $this->deltaPct($newInPeriod, $newPrevPeriod), 'total_deposits' => $totalDeposits, 'deposits_in_period' => $depositsInPeriod, 'deposits_delta_pct' => $this->deltaPct($depositsInPeriod, $depositsPrevPeriod), 'total_balance' => $totalBalance, 'total_active_services' => $totalActiveServices, 'expiring_services_7d' => $expiring7d, 'orders_in_period' => $ordersInPeriod, 'pending_orders' => $pendingOrders, 'orders_delta_pct' => $this->deltaPct($ordersInPeriod, $ordersPrevPeriod), ], ]); } public function monthlyStats(Request $request): JsonResponse { $year = $request->integer('year', now()->year); $deposits = Transaction::whereIn('type', self::DEPOSIT_TYPES) ->where('status', TransactionStatus::COMPLETED->value) ->whereYear('created_at', $year) ->selectRaw('MONTH(created_at) as m, SUM(amount) as total') ->groupBy('m') ->pluck('total', 'm'); $newCustomers = Customer::whereYear('created_at', $year) ->selectRaw('MONTH(created_at) as m, COUNT(*) as c') ->groupBy('m') ->pluck('c', 'm'); $orders = Order::whereYear('created_at', $year) ->selectRaw('MONTH(created_at) as m, COUNT(*) as c') ->groupBy('m') ->pluck('c', 'm'); // Doanh thu dịch vụ = tổng invoice đã PAID trong tháng $serviceRevenue = Invoice::where('status', InvoiceStatus::PAID->value) ->whereYear('paid_at', $year) ->selectRaw('MONTH(paid_at) as m, SUM(total) as total') ->groupBy('m') ->pluck('total', 'm'); $data = collect(range(1, 12))->map(fn ($m) => [ 'month' => 'T' . $m, 'deposits' => (float) ($deposits[$m] ?? 0), 'new_customers' => (int) ($newCustomers[$m] ?? 0), 'orders' => (int) ($orders[$m] ?? 0), 'service_revenue' => (float) ($serviceRevenue[$m] ?? 0), ])->values(); return response()->json(['error' => false, 'data' => $data]); } public function dailyStats(Request $request): JsonResponse { $month = $request->integer('month', now()->month); $year = $request->integer('year', now()->year); $start = Carbon::create($year, $month, 1)->startOfMonth(); $end = $start->copy()->endOfMonth(); $daysInMonth = $start->daysInMonth; $orders = Order::whereBetween('created_at', [$start, $end]) ->selectRaw('DAY(created_at) as d, COUNT(*) as c') ->groupBy('d') ->pluck('c', 'd'); $depositsAgg = Transaction::whereIn('type', self::DEPOSIT_TYPES) ->where('status', TransactionStatus::COMPLETED->value) ->whereBetween('created_at', [$start, $end]) ->selectRaw('DAY(created_at) as d, COUNT(*) as c, SUM(amount) as total') ->groupBy('d') ->get() ->keyBy('d'); $data = collect(range(1, $daysInMonth))->map(fn ($d) => [ 'day' => str_pad((string) $d, 2, '0', STR_PAD_LEFT), 'orders' => (int) ($orders[$d] ?? 0), 'deposits_count' => (int) ($depositsAgg[$d]->c ?? 0), 'deposits_amount' => (float) ($depositsAgg[$d]->total ?? 0), ])->values(); return response()->json(['error' => false, 'data' => $data]); } public function recentOrders(Request $request): JsonResponse { $limit = (int) $request->integer('limit', 8); $limit = max(1, min(50, $limit)); $orders = Order::with([ 'customer:id,uuid,fullname,email', 'orderItems:id,order_id,product_id', 'orderItems.product:id,name', 'invoice:id,order_id,status', ]) ->orderByDesc('created_at') ->limit($limit) ->get(); $items = $orders->map(function (Order $o) { $firstItem = $o->orderItems->first(); $product = $firstItem?->product; $invoiceStatus = $o->invoice?->status ?? null; return [ 'id' => $o->id, 'code' => $o->order_num, 'customer_id' => $o->customer_id, 'customer_uuid' => $o->customer?->uuid, 'customer_name' => $o->customer->fullname ?? '—', 'customer_email' => $o->customer->email ?? '', 'product_id' => $product?->id, 'service_name' => $product?->name ?? '—', 'amount' => (float) $o->total, 'payment_status' => $this->paymentStatusFromInvoice($invoiceStatus), 'status' => $this->orderStatusLabel((int) $o->status), 'created_at' => $o->created_at?->toISOString(), ]; })->values(); return response()->json([ 'error' => false, 'data' => ['items' => $items, 'total' => Order::count()], ]); } public function recentDeposits(Request $request): JsonResponse { $limit = (int) $request->integer('limit', 6); $limit = max(1, min(50, $limit)); $txs = Transaction::with('customer:id,fullname') ->whereIn('type', self::DEPOSIT_TYPES) ->orderByDesc('created_at') ->limit($limit) ->get(); $items = $txs->map(function (Transaction $t) { $status = match ((int) $t->status) { TransactionStatus::COMPLETED->value => 'success', TransactionStatus::PENDING->value => 'pending', TransactionStatus::FAILED->value => 'failed', default => 'pending', }; return [ 'id' => $t->id, 'code' => $t->public_code ?? $t->trans ?? ('TX-' . $t->id), 'customer_id' => $t->customer_id, 'customer_name' => $t->customer->fullname ?? '—', 'amount' => (float) $t->amount, 'method' => (string) ($t->type ?? 'manual'), 'status' => $status, 'created_at' => $t->created_at?->toISOString(), ]; })->values(); return response()->json([ 'error' => false, 'data' => [ 'items' => $items, 'total' => Transaction::whereIn('type', self::DEPOSIT_TYPES)->count(), ], ]); } public function topServices(Request $request): JsonResponse { $limit = (int) $request->integer('limit', 5); $limit = max(1, min(20, $limit)); [$start, $end, $prevStart, $prevEnd] = $this->periodFromRequest($request); // Top theo số service đang active của từng product $rows = Service::query() ->select( 'services.product_id', 'products.name as product_name', 'products.slug as product_slug', DB::raw('COUNT(*) as active_count') ) ->join('products', 'products.id', '=', 'services.product_id') ->where('services.status', 'active') ->whereNotNull('services.product_id') ->groupBy('services.product_id', 'products.name', 'products.slug') ->orderByDesc('active_count') ->limit($limit) ->get(); $productIds = $rows->pluck('product_id')->all(); // Đã bán & doanh thu kỳ hiện tại / kỳ trước (theo paid invoice item) $soldCurrent = DB::table('order_items') ->join('orders', 'orders.id', '=', 'order_items.order_id') ->whereIn('order_items.product_id', $productIds) ->whereBetween('orders.created_at', [$start, $end]) ->selectRaw('order_items.product_id, COUNT(*) as sold, SUM(order_items.price * order_items.quantity) as revenue') ->groupBy('order_items.product_id') ->get() ->keyBy('product_id'); $soldPrev = DB::table('order_items') ->join('orders', 'orders.id', '=', 'order_items.order_id') ->whereIn('order_items.product_id', $productIds) ->whereBetween('orders.created_at', [$prevStart, $prevEnd]) ->selectRaw('order_items.product_id, SUM(order_items.price * order_items.quantity) as revenue') ->groupBy('order_items.product_id') ->get() ->keyBy('product_id'); $data = $rows->map(function ($r) use ($soldCurrent, $soldPrev) { $cur = (float) ($soldCurrent[$r->product_id]->revenue ?? 0); $prev = (float) ($soldPrev[$r->product_id]->revenue ?? 0); return [ 'id' => (int) $r->product_id, 'name' => $r->product_name, 'code' => $r->product_slug, 'active_count' => (int) $r->active_count, 'sold_count' => (int) ($soldCurrent[$r->product_id]->sold ?? 0), 'revenue' => $cur, 'trend_pct' => $this->deltaPct($cur, $prev), ]; })->values(); return response()->json(['error' => false, 'data' => $data]); } public function expiringServices(Request $request): JsonResponse { $withinDays = (int) $request->integer('within_days', 7); $withinDays = max(1, min(60, $withinDays)); $now = now(); $until = $now->copy()->addDays($withinDays); $services = Service::with(['customer:id,fullname', 'product:id,name']) ->where('status', 'active') ->whereNotNull('expires_at') ->whereBetween('expires_at', [$now, $until]) ->orderBy('expires_at') ->limit(20) ->get(); $data = $services->map(function (Service $s) use ($now) { $expires = $s->expires_at instanceof Carbon ? $s->expires_at : Carbon::parse((string) $s->expires_at); return [ 'id' => $s->id, 'service_name' => $s->product->name ?? ($s->server_name ?? 'Dịch vụ'), 'customer_id' => $s->customer_id, 'customer_name' => $s->customer->fullname ?? '—', 'expired_at' => $expires->toISOString(), 'days_left' => max(0, (int) $now->diffInDays($expires, false)), 'amount' => 0.0, ]; })->values(); return response()->json(['error' => false, 'data' => $data]); } public function activities(Request $request): JsonResponse { $limit = (int) $request->integer('limit', 8); $limit = max(1, min(30, $limit)); // Tổng hợp 3 nguồn rồi sort theo thời gian $orders = Order::with('customer:id,fullname,email') ->orderByDesc('created_at') ->limit($limit) ->get() ->map(function (Order $o) { $isCancelled = (int) $o->status === OrderStatus::CANCELLED->value; $isActive = (int) $o->status === OrderStatus::ACTIVE->value; $type = $isCancelled ? 'order_cancelled' : ($isActive ? 'order_completed' : 'order_created'); return [ 'id' => 'order-' . $o->id, 'type' => $type, 'title' => 'Đơn ' . $o->order_num . ' ' . match ($type) { 'order_cancelled' => 'đã huỷ', 'order_completed' => 'đã kích hoạt', default => 'được tạo', }, 'description' => ($o->customer->fullname ?? '—') . ' · ' . number_format((float) $o->total, 0, ',', '.') . 'đ', 'customer_email' => $o->customer->email ?? null, 'created_at' => $o->created_at?->toISOString(), '_ts' => $o->created_at?->timestamp ?? 0, ]; }); $deposits = Transaction::with('customer:id,fullname,email') ->whereIn('type', self::DEPOSIT_TYPES) ->orderByDesc('created_at') ->limit($limit) ->get() ->map(function (Transaction $t) { $ok = (int) $t->status === TransactionStatus::COMPLETED->value; return [ 'id' => 'tx-' . $t->id, 'type' => $ok ? 'deposit_success' : 'deposit_failed', 'title' => $ok ? 'Nạp tiền thành công' : 'Giao dịch nạp thất bại', 'description' => ($t->customer->fullname ?? '—') . ' · ' . number_format((float) $t->amount, 0, ',', '.') . 'đ', 'customer_email' => $t->customer->email ?? null, 'created_at' => $t->created_at?->toISOString(), '_ts' => $t->created_at?->timestamp ?? 0, ]; }); $customers = Customer::orderByDesc('created_at') ->limit($limit) ->get(['id', 'fullname', 'email', 'created_at']) ->map(fn (Customer $c) => [ 'id' => 'customer-' . $c->id, 'type' => 'customer_registered', 'title' => 'Khách hàng mới đăng ký', 'description' => ($c->fullname ?: $c->email) ?? '—', 'customer_email' => $c->email, 'created_at' => $c->created_at?->toISOString(), '_ts' => $c->created_at?->timestamp ?? 0, ]); $data = $orders->concat($deposits)->concat($customers) ->sortByDesc('_ts') ->take($limit) ->map(function ($a) { unset($a['_ts']); return $a; }) ->values(); return response()->json(['error' => false, 'data' => $data]); } public function latestCustomers(): JsonResponse { $customers = Customer::orderByDesc('created_at') ->limit(5) ->get(['id', 'uuid', 'fullname', 'email', 'total', 'created_at']); return response()->json([ 'error' => false, 'data' => [ 'items' => $customers->map(fn (Customer $c) => [ 'id' => $c->id, 'uuid' => $c->uuid, 'fullname' => $c->fullname, 'email' => $c->email, 'total' => (float) $c->total, 'created_at' => $c->created_at?->toISOString(), ])->values(), 'total' => Customer::count(), ], ]); } }