This commit is contained in:
baodev0
2026-09-28 16:39:22 +07:00
parent 82080b5b84
commit b057d23d71
10 changed files with 265 additions and 29 deletions
Vendored
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -53,28 +53,44 @@ class DashboardController extends Controller
return round((($current - $previous) / $previous) * 100, 2);
}
public function stats(): JsonResponse
/**
* @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();
$startOfMonth = $now->copy()->startOfMonth();
$startOfPrevMonth = $now->copy()->subMonthNoOverflow()->startOfMonth();
$endOfPrevMonth = $now->copy()->subMonthNoOverflow()->endOfMonth();
[$start, $end, $prevStart, $prevEnd] = $this->periodFromRequest($request);
// Customers
$totalCustomers = Customer::count();
$newThisMonth = Customer::where('created_at', '>=', $startOfMonth)->count();
$newPrevMonth = Customer::whereBetween('created_at', [$startOfPrevMonth, $endOfPrevMonth])->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');
$depositsThisMonth = (float) (clone $depositBase)
->where('created_at', '>=', $startOfMonth)
$depositsInPeriod = (float) (clone $depositBase)
->whereBetween('created_at', [$start, $end])
->sum('amount');
$depositsPrevMonth = (float) (clone $depositBase)
->whereBetween('created_at', [$startOfPrevMonth, $endOfPrevMonth])
$depositsPrevPeriod = (float) (clone $depositBase)
->whereBetween('created_at', [$prevStart, $prevEnd])
->sum('amount');
// Số dư ví khách hàng (cột customers.total)
@@ -88,29 +104,29 @@ class DashboardController extends Controller
->count();
// Orders
$totalOrdersThisMonth = Order::where('created_at', '>=', $startOfMonth)->count();
$totalOrdersPrevMonth = Order::whereBetween('created_at', [$startOfPrevMonth, $endOfPrevMonth])->count();
$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_this_month' => $newThisMonth,
'new_customers_delta_pct' => $this->deltaPct($newThisMonth, $newPrevMonth),
'new_customers_in_period' => $newInPeriod,
'new_customers_delta_pct' => $this->deltaPct($newInPeriod, $newPrevPeriod),
'total_deposits' => $totalDeposits,
'deposits_this_month' => $depositsThisMonth,
'deposits_delta_pct' => $this->deltaPct($depositsThisMonth, $depositsPrevMonth),
'deposits_in_period' => $depositsInPeriod,
'deposits_delta_pct' => $this->deltaPct($depositsInPeriod, $depositsPrevPeriod),
'total_balance' => $totalBalance,
'total_active_services' => $totalActiveServices,
'expiring_services_7d' => $expiring7d,
'total_orders_this_month' => $totalOrdersThisMonth,
'orders_in_period' => $ordersInPeriod,
'pending_orders' => $pendingOrders,
'orders_delta_pct' => $this->deltaPct($totalOrdersThisMonth, $totalOrdersPrevMonth),
'orders_delta_pct' => $this->deltaPct($ordersInPeriod, $ordersPrevPeriod),
],
]);
}
@@ -192,7 +208,7 @@ class DashboardController extends Controller
$limit = max(1, min(50, $limit));
$orders = Order::with([
'customer:id,fullname,email',
'customer:id,uuid,fullname,email',
'orderItems:id,order_id,product_id',
'orderItems.product:id,name',
'invoice:id,order_id,status',
@@ -203,16 +219,18 @@ class DashboardController extends Controller
$items = $orders->map(function (Order $o) {
$firstItem = $o->orderItems->first();
$serviceName = $firstItem?->product?->name ?? '—';
$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 ?? '',
'service_name' => $serviceName,
'product_id' => $product?->id,
'service_name' => $product?->name ?? '—',
'amount' => (float) $o->total,
'payment_status' => $this->paymentStatusFromInvoice($invoiceStatus),
'status' => $this->orderStatusLabel((int) $o->status),
@@ -271,9 +289,7 @@ class DashboardController extends Controller
$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();
[$start, $end, $prevStart, $prevEnd] = $this->periodFromRequest($request);
// Top theo số service đang active của từng product
$rows = Service::query()
@@ -297,7 +313,7 @@ class DashboardController extends Controller
$soldCurrent = DB::table('order_items')
->join('orders', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.product_id', $productIds)
->where('orders.created_at', '>=', $startOfMonth)
->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()
@@ -306,7 +322,7 @@ class DashboardController extends Controller
$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])
->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()
@@ -22,7 +22,11 @@ class StaffOnlineController extends Controller
*/
public function index(): JsonResponse
{
$users = User::whereHas('staffOnline')->with('staffOnline')->get();
$onlineSince = fn ($q) => $q->where('last_visited', '>=', time() - self::ONLINE_THRESHOLD_SECONDS);
$users = User::whereHas('staffOnline', $onlineSince)
->with(['staffOnline' => $onlineSince])
->get();
$online = $users->map(fn ($user) => [
'user_id' => $user->id,
@@ -5,6 +5,7 @@ namespace App\Services\Customer;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\Transaction;
use App\Repositories\Customer\CustomerRepositoryInterface;
use App\Repositories\Invoice\InvoiceRepositoryInterface;
use App\Repositories\Order\OrderRepositoryInterface;
use App\Repositories\Transaction\TransactionRepositoryInterface;
@@ -16,6 +17,7 @@ class CustomerBillingService
use ResolvesCustomer;
public function __construct(
private readonly CustomerRepositoryInterface $customerRepository,
private readonly InvoiceRepositoryInterface $invoiceRepository,
private readonly TransactionRepositoryInterface $transactionRepository,
private readonly OrderRepositoryInterface $orderRepository,
@@ -5,6 +5,7 @@ namespace App\Services\Customer;
use App\Http\Resources\Api\Client\ClientResource;
use App\Models\Log;
use App\Models\User;
use App\Repositories\Customer\CustomerRepositoryInterface;
use App\Services\Api\Client\ClientAuthService;
use App\Services\Customer\Concerns\ResolvesCustomer;
use Illuminate\Http\Request;
@@ -19,6 +20,7 @@ class CustomerImpersonationService
private const TTL_SECONDS = 120;
public function __construct(
private readonly CustomerRepositoryInterface $customerRepository,
private readonly ClientAuthService $clientAuthService,
) {}
@@ -3,6 +3,7 @@
namespace App\Services\Customer;
use App\Models\Note;
use App\Repositories\Customer\CustomerRepositoryInterface;
use App\Services\Customer\Concerns\ResolvesCustomer;
use Illuminate\Support\Collection;
@@ -10,6 +11,10 @@ class CustomerNoteService
{
use ResolvesCustomer;
public function __construct(
private readonly CustomerRepositoryInterface $customerRepository,
) {}
/**
* Ghi chú của khách hàng (không gắn với dịch vụ cụ thể).
*/
@@ -16,6 +16,7 @@ use App\Models\Product;
use App\Models\Service;
use App\Models\Transaction;
use App\Models\VirtualMachines;
use App\Repositories\Customer\CustomerRepositoryInterface;
use App\Repositories\Module\Hosting\HostingRepositoryInterface as ModuleHostingRepositoryInterface;
use App\Repositories\Module\Vmware\VmwareRepositoryInterface;
use App\Repositories\Service\ServiceRepositoryInterface;
@@ -32,6 +33,7 @@ class CustomerServiceProvisioningService
use ResolvesCustomer;
public function __construct(
private readonly CustomerRepositoryInterface $customerRepository,
private readonly ModuleHostingRepositoryInterface $moduleHostingRepository,
private readonly VmwareRepositoryInterface $vmwareRepository,
private readonly ServiceRepositoryInterface $serviceRepository,
@@ -0,0 +1,205 @@
<?php
namespace Database\Seeders;
use App\Enums\Invoice\InvoiceStatus;
use App\Enums\Order\OrderStatus;
use App\Enums\TransactionStatus;
use App\Helpers\OrderHelper;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\Service;
use App\Models\Transaction;
use Carbon\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Chạy: php artisan db:seed --class=CustomerBillingDemoSeeder
*/
class CustomerBillingDemoSeeder extends Seeder
{
private const MARKER = 'seed:customer-billing-demo';
private const SCENARIOS = [
['product' => 'email-doanh-nghiep-goi-10-tai-khoan', 'cycle' => 'monthly', 'status' => InvoiceStatus::PAID, 'paid' => 1.0, 'created' => -25, 'due' => -18],
['product' => 'switch-cisco-catalyst-24-port', 'cycle' => 'onetime', 'status' => InvoiceStatus::PAID, 'paid' => 1.0, 'created' => -60, 'due' => -53],
['product' => 'thue-may-chu-vat-ly-dell-r740', 'cycle' => 'monthly', 'status' => InvoiceStatus::UNPAID, 'paid' => 0.0, 'created' => -1, 'due' => 6],
['product' => 'it-support-goi-doanh-nghiep', 'cycle' => 'monthly', 'status' => InvoiceStatus::UNPAID, 'paid' => 0.5, 'created' => -3, 'due' => 4],
['product' => 'website-landing-page-co-ban', 'cycle' => 'onetime', 'status' => InvoiceStatus::OVERDUE, 'paid' => 0.0, 'created' => -17, 'due' => -10],
['product' => 'email-doanh-nghiep-goi-10-tai-khoan', 'cycle' => 'monthly', 'status' => InvoiceStatus::OVERDUE, 'paid' => 0.3, 'created' => -10, 'due' => -3],
['product' => 'it-support-goi-doanh-nghiep', 'cycle' => 'monthly', 'status' => InvoiceStatus::CANCELLED, 'paid' => 0.0, 'created' => -40, 'due' => -33],
['product' => 'thue-may-chu-vat-ly-dell-r740', 'cycle' => 'monthly', 'status' => InvoiceStatus::REFUNDED, 'paid' => 1.0, 'created' => -45, 'due' => -38],
];
public function run(): void
{
$this->call([TestAccountSeeder::class, ProductCategorySeeder::class, ProductTypeFieldSeeder::class, ProductMockupSeeder::class]);
$customer = Customer::where('email', 'pbao.dev@gmail.com')->firstOrFail();
DB::transaction(function () use ($customer) {
$this->purgePreviousRun($customer);
foreach (self::SCENARIOS as $scenario) {
$this->seedScenario($customer, $scenario);
}
});
$this->command?->info('Đã tạo ' . count(self::SCENARIOS) . ' đơn hàng/hoá đơn mẫu cho ' . $customer->email . '.');
}
private function purgePreviousRun(Customer $customer): void
{
$orderIds = Order::withTrashed()->where('customer_id', $customer->id)->where('note', self::MARKER)->pluck('id');
$invoices = Invoice::withTrashed()->whereIn('order_id', $orderIds)->get(['id', 'invoice_number']);
Transaction::withTrashed()
->where('customer_id', $customer->id)
->where(function ($query) use ($invoices) {
foreach ($invoices->pluck('invoice_number') as $number) {
$query->orWhere('trans', 'like', $number . '%');
}
})
->when($invoices->isNotEmpty(), fn ($query) => $query->forceDelete());
$orderItemIds = OrderItem::whereIn('order_id', $orderIds)->pluck('id');
Service::withTrashed()->whereIn('order_item_id', $orderItemIds)->forceDelete();
InvoiceItem::whereIn('invoice_id', $invoices->pluck('id'))->delete();
Invoice::withTrashed()->whereIn('id', $invoices->pluck('id'))->forceDelete();
OrderItem::whereIn('id', $orderItemIds)->delete();
Order::withTrashed()->whereIn('id', $orderIds)->forceDelete();
}
/**
* @param array{product: string, cycle: string, status: InvoiceStatus, paid: float, created: int, due: int} $scenario
*/
private function seedScenario(Customer $customer, array $scenario): void
{
$product = Product::where('slug', $scenario['product'])->firstOrFail();
$price = (int) $product->pricings()->where('billing_cycle', $scenario['cycle'])->value('price');
$months = OrderHelper::cycleToMonths($scenario['cycle']);
$status = $scenario['status'];
$createdAt = Carbon::now()->addDays($scenario['created']);
$amountPaid = (int) round($price * $scenario['paid'], -3);
$isSettled = in_array($status, [InvoiceStatus::PAID, InvoiceStatus::REFUNDED], true);
$paidAt = $isSettled ? $createdAt->copy()->addHour() : null;
$order = Order::query()->forceCreate([
'order_num' => OrderHelper::generateOrderNumber(),
'date' => $createdAt->toDateString(),
'customer_id' => $customer->id,
'sub_total' => $price,
'discount_amount' => 0,
'total' => $price,
'status' => match ($status) {
InvoiceStatus::PAID => OrderStatus::ACTIVE->value,
InvoiceStatus::CANCELLED, InvoiceStatus::REFUNDED => OrderStatus::CANCELLED->value,
default => OrderStatus::PENDING->value,
},
'payment_method' => $amountPaid > 0 ? 'balance' : null,
'paid_at' => $paidAt,
'note' => self::MARKER,
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
$orderItem = OrderItem::query()->forceCreate([
'order_id' => $order->id,
'product_id' => $product->id,
'product_pricing_id' => $product->pricings()->where('billing_cycle', $scenario['cycle'])->value('id'),
'billing_cycle' => $scenario['cycle'],
'months' => $months,
'quantity' => 1,
'price' => $price,
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
$invoice = Invoice::query()->forceCreate([
'invoice_number' => OrderHelper::generateOrderNumber('INV'),
'date' => $createdAt->toDateString(),
'customer_id' => $customer->id,
'order_id' => $order->id,
'invoice_type' => 1,
'sub_total' => $price,
'discount_amount' => 0,
'tax' => 0,
'total' => $price,
'amount_paid' => $amountPaid,
'status' => $status->value,
'payment_method' => $amountPaid > 0 ? 'balance' : null,
'due_date' => Carbon::now()->addDays($scenario['due'])->toDateString(),
'billing_period_start' => $createdAt->toDateString(),
'billing_period_end' => $createdAt->copy()->addMonths($months)->toDateString(),
'paid_at' => $paidAt,
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
InvoiceItem::query()->forceCreate([
'invoice_id' => $invoice->id,
'product_id' => $product->id,
'type' => 'service',
'description' => $product->name,
'months' => $months,
'quantity' => 1,
'unit_price' => $price,
'total_price' => $price,
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
if ($amountPaid > 0) {
$this->recordPayment($invoice, $amountPaid, $isSettled, $paidAt ?? $createdAt->copy()->addHour());
}
if ($isSettled) {
$this->seedService($order, $orderItem, $invoice, $status, $paidAt);
}
}
private function recordPayment(Invoice $invoice, int $amount, bool $isSettled, Carbon $paidAt): void
{
Transaction::query()->forceCreate([
'uuid' => (string) Str::uuid(),
'trans' => $invoice->invoice_number . ($isSettled ? '' : '-P' . $amount),
'public_code' => 'INV-PAY-' . $paidAt->format('Ymd') . '-' . strtoupper(Str::random(6)),
'amount' => $amount,
'received_amount' => $amount,
'customer_id' => $invoice->customer_id,
'status' => TransactionStatus::COMPLETED->value,
'type' => 'invoice',
'note' => 'Khách hàng thanh toán hóa đơn #' . $invoice->invoice_number . ' (balance)',
'created_at' => $paidAt,
'updated_at' => $paidAt,
]);
}
private function seedService(Order $order, OrderItem $orderItem, Invoice $invoice, InvoiceStatus $status, Carbon $startedAt): void
{
$isRefunded = $status === InvoiceStatus::REFUNDED;
$refundedAt = $startedAt->copy()->addDays(2);
Service::query()->forceCreate([
'uuid' => (string) Str::uuid(),
'customer_id' => $order->customer_id,
'product_id' => $orderItem->product_id,
'order_item_id' => $orderItem->id,
'invoice_id' => $invoice->id,
'status' => $isRefunded ? 'cancelled' : 'active',
'auto_renew' => false,
'started_at' => $startedAt,
'expires_at' => $startedAt->copy()->addMonths($orderItem->months),
'cancelled_at' => $isRefunded ? $refundedAt : null,
'refunded_at' => $isRefunded ? $refundedAt : null,
'created_at' => $startedAt,
'updated_at' => $isRefunded ? $refundedAt : $startedAt,
]);
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ use Illuminate\Support\Str;
class TestAccountSeeder extends Seeder
{
private const CUSTOMER_EMAIL = 'pbao.dev@gmail.com';
private const CUSTOMER_PASSWORD = 'password123';
private const CUSTOMER_PASSWORD = '!admin@123!@#';
private const ADMIN_USERNAME = 'admin';
private const ADMIN_PASSWORD = '!admin@123!@#';