321 lines
12 KiB
PHP
321 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Api\Order;
|
|
|
|
use App\Enums\Order\OrderStatus;
|
|
use App\Helpers\OrderHelper;
|
|
use App\Jobs\Mail\Admin\AdminOrderNotificationJob;
|
|
use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
|
|
use App\Models\VmProvisionLock;
|
|
use App\Models\Addon;
|
|
use App\Models\Log as CustomerActivityLog;
|
|
use App\Models\Order;
|
|
use App\Models\OrderItem;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceAddon;
|
|
use App\Repositories\Api\Invoice\InvoiceRepository;
|
|
use App\Repositories\Api\Order\OrderRepository;
|
|
use App\Repositories\Api\Service\ServiceRepository;
|
|
use App\Services\InvoicePaymentService;
|
|
use Exception;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class OrderService
|
|
{
|
|
public function __construct(
|
|
protected OrderRepository $orderRepo,
|
|
protected ServiceRepository $productRepo,
|
|
protected InvoiceRepository $invoiceRepo,
|
|
protected InvoicePaymentService $invoicePaymentService,
|
|
) {}
|
|
|
|
// =========================================================================
|
|
// create — Step 1: Customer places an order
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Validate → calculate price → persist Order + OrderItem + Invoice.
|
|
*
|
|
* @return array{order: Order, invoice: \App\Models\Invoice}
|
|
* @throws Exception
|
|
*/
|
|
public function create(array $data, $customer): array
|
|
{
|
|
if ($this->orderRepo->hasPendingOrder($customer->id)) {
|
|
throw new Exception('Bạn có đơn hàng chưa thanh toán. Vui lòng thanh toán trước khi tạo đơn mới.');
|
|
}
|
|
|
|
$product = $this->productRepo->find($data['product_id']);
|
|
if (!$product) {
|
|
throw new Exception('Sản phẩm không tồn tại.');
|
|
}
|
|
|
|
$basePrice = $this->productRepo->getPricing($product->id, $data['billing_cycle']);
|
|
if (is_null($basePrice)) {
|
|
throw new Exception('Không tìm thấy giá cho chu kỳ thanh toán này.');
|
|
}
|
|
|
|
// Resolve addons → enriched meta with price & subtotal
|
|
$addonsMeta = $this->resolveAddons($data['addons'] ?? []);
|
|
$addonsPrice = array_sum(array_column($addonsMeta, 'subtotal'));
|
|
$total = $basePrice + $addonsPrice;
|
|
|
|
if ($customer->total < $total) {
|
|
throw new Exception(sprintf(
|
|
'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.',
|
|
number_format($customer->total),
|
|
number_format($total)
|
|
));
|
|
}
|
|
|
|
return DB::transaction(function () use ($data, $customer, $product, $basePrice, $total, $addonsMeta) {
|
|
$order = $this->orderRepo->create([
|
|
'order_num' => OrderHelper::generateOrderNumber(),
|
|
'customer_id' => $customer->id,
|
|
'sub_total' => $basePrice,
|
|
'discount_amount' => 0,
|
|
'total' => $total,
|
|
'status' => OrderStatus::PENDING->value,
|
|
'client_ip' => request()?->ip(),
|
|
]);
|
|
|
|
$this->orderRepo->createItem($order->id, [
|
|
'product_id' => $product->id,
|
|
'billing_cycle' => $data['billing_cycle'],
|
|
'months' => $this->cycleToMonths($data['billing_cycle']),
|
|
'server_name' => $data['server_name'] ?? null,
|
|
'os_id' => $data['os_id'] ?? null,
|
|
'quantity' => 1,
|
|
'price' => $basePrice,
|
|
'addons' => $addonsMeta, // serialised to addons_meta JSON
|
|
]);
|
|
|
|
// Reload items so invoice repo can iterate them
|
|
$order->load('orderItems.product');
|
|
|
|
$invoice = $this->invoiceRepo->createFromOrder($order);
|
|
|
|
return ['order' => $order, 'invoice' => $invoice];
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// pay — Step 2: Customer pays the pending order
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Debit customer balance → mark Order+Invoice paid → provision Service records.
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
public function pay(Order $order, string $method): Order
|
|
{
|
|
if (in_array($order->status, [OrderStatus::ACTIVE->value, OrderStatus::PROCESSING->value], true)) {
|
|
throw new Exception('Đơn hàng đã được thanh toán hoặc đang xử lý.');
|
|
}
|
|
|
|
return DB::transaction(function () use ($order, $method) {
|
|
$customer = $order->customer;
|
|
|
|
if ($customer->total < $order->total) {
|
|
throw new Exception(sprintf(
|
|
'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.',
|
|
number_format($customer->total),
|
|
number_format($order->total)
|
|
));
|
|
}
|
|
|
|
// Allow re-paying a cancelled order
|
|
if ($order->status === OrderStatus::CANCELLED->value) {
|
|
$order->update(['status' => OrderStatus::PENDING->value]);
|
|
}
|
|
|
|
if (! $order->invoice) {
|
|
throw new Exception('Không tìm thấy hóa đơn của đơn hàng.');
|
|
}
|
|
|
|
$this->invoicePaymentService->payWithBalance($order->invoice, $customer, $method);
|
|
$processedOrder = $order->fresh(['orderItems', 'invoice', 'customer']);
|
|
|
|
$this->dispatchAdminNotification($processedOrder, $customer);
|
|
|
|
return $processedOrder;
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// Private — provisionService
|
|
// =========================================================================
|
|
|
|
private function provisionService(Order $order, OrderItem $item, $customer): void
|
|
{
|
|
$invoice = $order->invoice;
|
|
$months = $item->months ?: $this->cycleToMonths($item->billing_cycle);
|
|
$expiresAt = now()->addMonths($months);
|
|
|
|
$service = Service::create([
|
|
'customer_id' => $customer->id,
|
|
'product_id' => $item->product_id,
|
|
'order_item_id' => $item->id,
|
|
'invoice_id' => $invoice?->id,
|
|
'server_name' => $item->server_name,
|
|
'status' => 'pending',
|
|
'auto_renew' => false,
|
|
'started_at' => now(),
|
|
'expires_at' => $expiresAt,
|
|
]);
|
|
|
|
$this->attachServiceAddons($service, $item);
|
|
|
|
// VPS: requires product + os_id → dispatch VM creation job
|
|
if ($item->product_id && $item->os_id) {
|
|
$this->dispatchVmJob($order, $item, $service, $customer);
|
|
} else {
|
|
// Hosting / Domain / non-VM services activate immediately
|
|
$service->update(['status' => 'active']);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create ServiceAddon rows from:
|
|
* 1. Base product specs (cpu, ram, disk, bandwidth)
|
|
* 2. Customer's configurable addon selections stored in addons_meta
|
|
*/
|
|
private function attachServiceAddons(Service $service, OrderItem $item): void
|
|
{
|
|
// 1. Base product specs
|
|
$product = $item->product;
|
|
if ($product) {
|
|
foreach (['cpu' => $product->cpu, 'ram' => $product->ram, 'disk' => $product->disk, 'bandwidth' => $product->bandwidth] as $type => $value) {
|
|
if ($value !== null && $value !== '') {
|
|
ServiceAddon::create([
|
|
'service_id' => $service->id,
|
|
'addon_type' => $type,
|
|
'addon_value' => (string) $value,
|
|
'price' => 0,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Customer-selected addons
|
|
$addonsMeta = is_array($item->addons_meta)
|
|
? $item->addons_meta
|
|
: json_decode($item->addons_meta ?? '[]', true);
|
|
|
|
foreach ((array) $addonsMeta as $meta) {
|
|
$addon = Addon::find($meta['addon_id'] ?? null);
|
|
if (!$addon) {
|
|
continue;
|
|
}
|
|
ServiceAddon::create([
|
|
'service_id' => $service->id,
|
|
'addon_type' => strtolower((string) $addon->code),
|
|
'addon_value' => (string) ($meta['qty'] ?? 1),
|
|
'price' => (int) ($meta['price'] ?? $addon->price),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
private function dispatchVmJob(Order $order, OrderItem $item, Service $service, $customer): void
|
|
{
|
|
try {
|
|
if (VmProvisionLock::isRunning($customer->id)) {
|
|
Log::warning('dispatchVmJob: blocked — customer already has a running VM provision', [
|
|
'customer_id' => $customer->id,
|
|
'order_item_id' => $item->id,
|
|
]);
|
|
return;
|
|
}
|
|
|
|
dispatch(new CreateVirtualMachineJob($item));
|
|
|
|
Log::info('dispatchVmJob: CreateVirtualMachineJob dispatched', [
|
|
'order_id' => $order->id,
|
|
'service_id' => $service->id,
|
|
'order_item_id' => $item->id,
|
|
]);
|
|
} catch (Exception $e) {
|
|
Log::error('dispatchVmJob: exception', [
|
|
'order_id' => $order->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Helpers
|
|
// =========================================================================
|
|
|
|
private function cycleToMonths(?string $cycle): int
|
|
{
|
|
return match ($cycle) {
|
|
'monthly' => 1,
|
|
'quarterly' => 3,
|
|
'semi_annually' => 6,
|
|
'annually' => 12,
|
|
'biennially' => 24,
|
|
'triennially' => 36,
|
|
default => 1,
|
|
};
|
|
}
|
|
|
|
private function dispatchAdminNotification(Order $order, $customer): void
|
|
{
|
|
try {
|
|
$item = $order->orderItems->first();
|
|
$product = $item?->product;
|
|
$productType = $product?->category?->name ?? $product?->type ?? 'N/A';
|
|
|
|
AdminOrderNotificationJob::dispatch([
|
|
'order_num' => $order->order_num,
|
|
'customer_name' => trim($customer->firstname . ' ' . $customer->lastname),
|
|
'customer_email' => $customer->email,
|
|
'client_ip' => $order->client_ip ?? request()?->ip(),
|
|
'product_name' => $product?->name ?? 'N/A',
|
|
'product_type' => $productType,
|
|
'billing_cycle' => $item?->billing_cycle ?? 'N/A',
|
|
'total' => $order->total,
|
|
'created_at' => now()->format('d/m/Y H:i:s'),
|
|
]);
|
|
} catch (Exception $e) {
|
|
Log::error('OrderService: failed to dispatch admin notification', [
|
|
'order_id' => $order->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enrich raw addon entries with resolved price & subtotal.
|
|
*
|
|
* Input: [['addon_id' => 1, 'qty' => 2], ...]
|
|
* Output: [['addon_id' => 1, 'qty' => 2, 'price' => 50000, 'subtotal' => 100000], ...]
|
|
*/
|
|
private function resolveAddons(array $raw): array
|
|
{
|
|
$resolved = [];
|
|
foreach ($raw as $entry) {
|
|
$addon = Addon::find($entry['addon_id'] ?? null);
|
|
if (!$addon) {
|
|
continue;
|
|
}
|
|
$qty = max(1, (int) ($entry['qty'] ?? 1));
|
|
$resolved[] = [
|
|
'addon_id' => $addon->id,
|
|
'code' => strtolower((string) $addon->code),
|
|
'name' => $addon->name,
|
|
'qty' => $qty,
|
|
'price' => $addon->price,
|
|
'subtotal' => $addon->price * $qty,
|
|
];
|
|
}
|
|
return $resolved;
|
|
}
|
|
}
|