Files
portal-api/app/Http/Controllers/Api/Admin/Dashboard/DashboardController.php
2026-10-20 09:58:38 +07:00

479 lines
19 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Admin\Dashboard;
use App\Enums\Invoice\InvoiceStatus;
use App\Enums\Order\OrderStatus;
use App\Enums\TransactionStatus;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\Service;
use App\Models\Transaction;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DashboardController extends Controller
{
// Các type được tính là "nạp tiền vào ví"
private const DEPOSIT_TYPES = ['manual', 'vnpay', 'deposit', 'admin_add_total', 'admin_add_credit'];
/** Map Order status int → string FE */
private function orderStatusLabel(int $status): string
{
return match ($status) {
OrderStatus::PENDING->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(),
],
]);
}
}