479 lines
19 KiB
PHP
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(),
|
|
],
|
|
]);
|
|
}
|
|
}
|