This commit is contained in:
baodev0
2026-06-22 08:43:32 +07:00
parent 3243473843
commit 0c436153c6
22 changed files with 1389 additions and 95 deletions
@@ -2,28 +2,115 @@
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'];
/** 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);
}
public function stats(): JsonResponse
{
$total_customers = Customer::count();
$now = now();
$startOfMonth = $now->copy()->startOfMonth();
$startOfPrevMonth = $now->copy()->subMonthNoOverflow()->startOfMonth();
$endOfPrevMonth = $now->copy()->subMonthNoOverflow()->endOfMonth();
$total_deposits = (float) Transaction::where('type', 'manual')
->where('status', TransactionStatus::COMPLETED->value)
// Customers
$totalCustomers = Customer::count();
$newThisMonth = Customer::where('created_at', '>=', $startOfMonth)->count();
$newPrevMonth = Customer::whereBetween('created_at', [$startOfPrevMonth, $endOfPrevMonth])->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');
$depositsThisMonth = (float) (clone $depositBase)
->where('created_at', '>=', $startOfMonth)
->sum('amount');
$depositsPrevMonth = (float) (clone $depositBase)
->whereBetween('created_at', [$startOfPrevMonth, $endOfPrevMonth])
->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
$totalOrdersThisMonth = Order::where('created_at', '>=', $startOfMonth)->count();
$totalOrdersPrevMonth = Order::whereBetween('created_at', [$startOfPrevMonth, $endOfPrevMonth])->count();
$pendingOrders = Order::where('status', OrderStatus::PENDING->value)->count();
return response()->json([
'error' => false,
'data' => [
'total_customers' => $total_customers,
'total_deposits' => $total_deposits,
'data' => [
'total_customers' => $totalCustomers,
'new_customers_this_month' => $newThisMonth,
'new_customers_delta_pct' => $this->deltaPct($newThisMonth, $newPrevMonth),
'total_deposits' => $totalDeposits,
'deposits_this_month' => $depositsThisMonth,
'deposits_delta_pct' => $this->deltaPct($depositsThisMonth, $depositsPrevMonth),
'total_balance' => $totalBalance,
'total_active_services' => $totalActiveServices,
'expiring_services_7d' => $expiring7d,
'total_orders_this_month' => $totalOrdersThisMonth,
'pending_orders' => $pendingOrders,
'orders_delta_pct' => $this->deltaPct($totalOrdersThisMonth, $totalOrdersPrevMonth),
],
]);
}
@@ -32,34 +119,322 @@ class DashboardController extends Controller
{
$year = $request->integer('year', now()->year);
$deposits = Transaction::where('type', 'manual')
$deposits = Transaction::whereIn('type', self::DEPOSIT_TYPES)
->where('status', TransactionStatus::COMPLETED->value)
->whereYear('created_at', $year)
->selectRaw('MONTH(created_at) as month, SUM(amount) as total_deposits')
->groupBy('month')
->pluck('total_deposits', 'month');
->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 month, COUNT(*) as new_customers')
->groupBy('month')
->pluck('new_customers', 'month');
->selectRaw('MONTH(created_at) as m, COUNT(*) as c')
->groupBy('m')
->pluck('c', 'm');
$months = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'T8', 'T9', 'T10', 'T11', 'T12'];
$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,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();
$serviceName = $firstItem?->product?->name ?? '—';
$invoiceStatus = $o->invoice?->status ?? null;
$data = collect(range(1, 12))->map(function ($m) use ($months, $deposits, $newCustomers) {
return [
'month' => $months[$m - 1],
'deposits' => (float) ($deposits[$m] ?? 0),
'new_customers' => (int) ($newCustomers[$m] ?? 0),
'id' => $o->id,
'code' => $o->order_num,
'customer_id' => $o->customer_id,
'customer_name' => $o->customer->fullname ?? '—',
'customer_email' => $o->customer->email ?? '',
'service_name' => $serviceName,
'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' => $data,
'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));
$startOfMonth = now()->startOfMonth();
$startOfPrevMonth = now()->copy()->subMonthNoOverflow()->startOfMonth();
$endOfPrevMonth = now()->copy()->subMonthNoOverflow()->endOfMonth();
// 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)
->where('orders.created_at', '>=', $startOfMonth)
->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', [$startOfPrevMonth, $endOfPrevMonth])
->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')
->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, ',', '.') . 'đ',
'created_at' => $o->created_at?->toISOString(),
'_ts' => $o->created_at?->timestamp ?? 0,
];
});
$deposits = Transaction::with('customer:id,fullname')
->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, ',', '.') . 'đ',
'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) ?? '—',
'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')
@@ -68,13 +443,13 @@ class DashboardController extends Controller
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,
'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(),
@@ -20,9 +20,10 @@ class DomainController extends Controller
public function index(Request $request): JsonResponse
{
$perPage = min((int) $request->get('per_page', 15), 500);
$filter = $request->get('filter');
$status = $request->get('status');
$perPage = min((int) $request->get('per_page', 15), 500);
$filter = $request->get('filter');
$status = $request->get('status');
$customerName = trim((string) $request->get('customer_name', ''));
$query = $this->domainRepository->model
->with(['server', 'customer'])
@@ -36,6 +37,12 @@ class DomainController extends Controller
$query->where('is_created', (int) $status);
}
if ($customerName !== '') {
$query->whereHas('customer', function ($q) use ($customerName) {
$q->where('fullname', 'like', "%{$customerName}%");
});
}
$paginator = $query->orderByDesc('id')->paginate($perPage);
$items = $paginator->getCollection()->map(fn($domain) => $this->formatDomain($domain));
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin\Provider;
use App\Http\Controllers\Controller;
use App\Services\VirtualMachine\VmActiveProviderService;
use Illuminate\Http\JsonResponse;
use Throwable;
/**
* Admin endpoints to toggle which row in the
* vcenter / datacenter / host / datastore / network chain is active.
*
* Only ONE row per parent scope can be active at a time — enforced by
* VmActiveProviderService::activate() inside a transaction. Deactivating
* a row cascade-deactivates its descendants.
*/
class ActiveInfraController extends Controller
{
public function __construct(private readonly VmActiveProviderService $service)
{
}
/** GET /administrator/provider/active-chain — returns the current active chain (or error). */
public function chain(): JsonResponse
{
try {
$chain = $this->service->resolveChain();
} catch (Throwable $e) {
return response()->json([
'error' => true,
'message' => $e->getMessage(),
], 422);
}
return response()->json([
'error' => false,
'data' => [
'vcenter' => $chain['vcenter']->only(['id', 'host', 'user', 'status']),
'datacenter' => $chain['datacenter']->only(['id', 'name', 'datacenter_id', 'status']),
'host' => $chain['host']->only(['id', 'name', 'host_id', 'status']),
'datastore' => $chain['datastore']->only(['id', 'name', 'datastore', 'status']),
'network' => $chain['network']->only(['id', 'name', 'network', 'status']),
],
]);
}
/** POST /administrator/provider/{type}/{id}/activate */
public function activate(string $type, int $id): JsonResponse
{
try {
$row = $this->service->activate($type, $id);
} catch (Throwable $e) {
return response()->json(['error' => true, 'message' => $e->getMessage()], 422);
}
return response()->json([
'error' => false,
'data' => $row,
]);
}
/** POST /administrator/provider/{type}/{id}/deactivate */
public function deactivate(string $type, int $id): JsonResponse
{
try {
$row = $this->service->deactivate($type, $id);
} catch (Throwable $e) {
return response()->json(['error' => true, 'message' => $e->getMessage()], 422);
}
return response()->json([
'error' => false,
'data' => $row,
]);
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin\Provider;
use App\Http\Controllers\Controller;
use App\Models\vCenter;
use App\Services\VirtualMachine\VmProviderSettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
/**
* Reads / writes the provider config blob in setting key=`provider`.
*
* active_vcenter_id is read-only here — it mirrors module_vcenter.status=1.
* To switch the active vCenter, call ActiveInfraController::activate().
*/
class ProviderSettingController extends Controller
{
public function __construct(private readonly VmProviderSettingsService $service)
{
}
/** GET /administrator/provider/settings */
public function index(): JsonResponse
{
$data = $this->service->all();
// Mirror current truth: active vCenter id from module_vcenter
$data['active_vcenter_id'] = vCenter::where('status', 1)->value('id');
return response()->json([
'error' => false,
'data' => $data,
]);
}
/** PUT /administrator/provider/settings */
public function update(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'default_folder' => 'sometimes|string|max:100',
'default_resource_pool' => 'sometimes|string|max:100',
'default_datastore' => 'sometimes|string|max:100',
'name' => 'sometimes|nullable|string|max:120',
'region' => 'sometimes|nullable|string|max:60',
'tags' => 'sometimes|array',
'tags.*' => 'string|max:40',
]);
if ($validator->fails()) {
return response()->json([
'error' => true,
'message' => $validator->errors()->first(),
], 422);
}
$patch = $validator->validated();
// active_vcenter_id is read-only — strip if client sent it
unset($patch['active_vcenter_id']);
$merged = $this->service->merge($patch);
$merged['active_vcenter_id'] = vCenter::where('status', 1)->value('id');
return response()->json([
'error' => false,
'data' => $merged,
]);
}
}