364 lines
15 KiB
PHP
364 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\Invoice\InvoiceStatus;
|
|
use App\Enums\Order\OrderStatus;
|
|
use App\Enums\Product\ProductBillingCycle;
|
|
use App\Enums\Product\ProductTypeEnums;
|
|
use App\Enums\TransactionStatus;
|
|
use App\Http\Helpers\Helper;
|
|
use App\Jobs\Hosting\CreateHostingJobs;
|
|
use App\Jobs\Mail\Customer\OrderCreatedJob;
|
|
use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
|
|
use App\Jobs\VirtualMachine\Upgrade\UpgradeVirtualMachine;
|
|
use App\Models\Customer;
|
|
use App\Models\Order;
|
|
use App\Models\OrderItem;
|
|
use App\Models\Payment;
|
|
use App\Models\Transaction;
|
|
use App\Repositories\Addon\AddonRepositoryInterface;
|
|
use App\Repositories\Invoice\InvoiceRepositoryInterface;
|
|
use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface;
|
|
use App\Repositories\Module\Hosting\HostingRepositoryInterface as ModuleHostingRepositoryInterface;
|
|
use App\Repositories\Module\HostingServer\HostingServerRepositoryInterface;
|
|
use App\Repositories\Order\OrderRepositoryInterface;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
|
|
class OrderService
|
|
{
|
|
public function __construct(
|
|
private readonly OrderRepositoryInterface $orderRepository,
|
|
private readonly InvoiceRepositoryInterface $invoiceRepository,
|
|
private readonly InvoiceItemRepositoryInterface $invoiceItemRepository,
|
|
private readonly ModuleHostingRepositoryInterface $moduleHostingRepository,
|
|
private readonly HostingServerRepositoryInterface $moduleHostingServerRepository,
|
|
private readonly AddonRepositoryInterface $addonRepository,
|
|
) {}
|
|
|
|
/**
|
|
* Xác nhận đơn hàng (status=1). Nếu $provisionModules, chạy thêm logic
|
|
* cấp phát hosting/VM/addon dựa trên các order item (module=1 từ FE).
|
|
*/
|
|
public function confirmOrder(Order $order, bool $provisionModules): void
|
|
{
|
|
DB::transaction(function () use ($order, $provisionModules) {
|
|
$this->orderRepository->update($order->id, ['status' => 1]);
|
|
|
|
if ($provisionModules) {
|
|
$this->provisionOrderItems($order);
|
|
}
|
|
});
|
|
}
|
|
|
|
public function setStatus(Order $order, int $status): void
|
|
{
|
|
$this->orderRepository->update($order->id, ['status' => $status]);
|
|
}
|
|
|
|
/**
|
|
* Xoá đơn hàng cùng hóa đơn và các dòng hóa đơn liên quan.
|
|
*/
|
|
public function deleteOrder(Order $order): void
|
|
{
|
|
DB::transaction(function () use ($order) {
|
|
if ($order->invoice) {
|
|
$order->invoice->invoiceItems()->delete();
|
|
$order->invoice->delete();
|
|
}
|
|
|
|
$order->delete();
|
|
});
|
|
}
|
|
|
|
private function provisionOrderItems(Order $order): void
|
|
{
|
|
$jobs = [];
|
|
$addon = [];
|
|
$product = 0;
|
|
|
|
foreach ($order->orderItem as $item) {
|
|
switch ((int) $item->type_id) {
|
|
case 1:
|
|
$itemData = $this->addonRepository->find($item->relid);
|
|
if ($itemData) {
|
|
$price = match ((int) $item->billing) {
|
|
2 => $item->amount / 1,
|
|
3 => $item->amount / 3,
|
|
4 => $item->amount / 6,
|
|
5 => $item->amount / 12,
|
|
default => 0
|
|
};
|
|
$addon[strtolower($itemData->name)] = (int) $price / $itemData->price;
|
|
$product = $item->parent_id;
|
|
}
|
|
break;
|
|
case 3:
|
|
$hostingServer = $this->moduleHostingServerRepository->get_active();
|
|
if ($hostingServer) {
|
|
$this->moduleHostingRepository->update($item->relid, ['module_server_id' => $hostingServer->first()->id]);
|
|
$jobs[] = new CreateHostingJobs($item->relid);
|
|
}
|
|
break;
|
|
// case 4 (VPS/Cloud): xử lý bởi CreateVirtualMachineJob, đã dispatch
|
|
// ngay lúc tạo đơn trong createOrder() — không provision lại ở đây.
|
|
}
|
|
}
|
|
|
|
if (count($order->orderItem) === count($addon)) {
|
|
dispatch(new UpgradeVirtualMachine($product, $addon, $order->id))->delay(now()->addSeconds(10));
|
|
}
|
|
|
|
foreach ($jobs as $item) {
|
|
dispatch($item)->delay(now()->addSeconds(10));
|
|
}
|
|
}
|
|
|
|
public function createOrder(array $data): Order
|
|
{
|
|
$customer = Customer::find($data['customer_id']);
|
|
if (!$customer || (int) $customer->status !== 1) {
|
|
throw new \InvalidArgumentException('Tài khoản khách hàng không hoạt động. Không thể tạo đơn hàng.');
|
|
}
|
|
|
|
$paymentRecord = Payment::find((int) $data['payment_method']);
|
|
$isBalancePayment = $paymentRecord && (int) $paymentRecord->type === 0;
|
|
|
|
// Balance payment → auto-confirm order, otherwise use requested order status.
|
|
$effectiveOrderStatus = $isBalancePayment ? OrderStatus::ACTIVE->value : (int) $data['status'];
|
|
$effectiveInvoiceStatus = match ($effectiveOrderStatus) {
|
|
OrderStatus::ACTIVE->value => InvoiceStatus::PAID->value,
|
|
OrderStatus::CANCELLED->value => InvoiceStatus::CANCELLED->value,
|
|
default => InvoiceStatus::UNPAID->value,
|
|
};
|
|
|
|
[$productItems, $relIdData, $total] = $this->buildProductItems($data);
|
|
|
|
// Giá niêm yết đã bao gồm thuế (khớp với client-facing OrderService::create()) —
|
|
// không cộng thêm thuế lên trên ở đây.
|
|
$orderTotal = (int) $total;
|
|
|
|
$invoiceData = [
|
|
'invoice_number' => Helper::generateUniqueCode(),
|
|
'due_date' => Carbon::today()->addDays(7)->toDateString(),
|
|
'paid_at' => $effectiveInvoiceStatus === InvoiceStatus::PAID->value ? Carbon::now() : null,
|
|
'sub_total' => $total,
|
|
'tax' => 0,
|
|
'total' => $orderTotal,
|
|
'payment_method' => $paymentRecord?->id,
|
|
'customer_id' => $data['customer_id'],
|
|
'status' => $effectiveInvoiceStatus,
|
|
];
|
|
|
|
$orderData = [
|
|
'order_num' => Helper::generateUniqueCode(),
|
|
'customer_id' => $data['customer_id'],
|
|
'discount_id' => $data['discount_id'] ?? null,
|
|
'sub_total' => $total,
|
|
'total' => $orderTotal,
|
|
'payment_method' => $paymentRecord?->code ?? null,
|
|
'payment_proof' => $data['payment_proof'] ?? null,
|
|
'user_id' => Auth::guard('admin')->id(),
|
|
'status' => $effectiveOrderStatus,
|
|
// Order.paid_at mirrors invoice.paid_at — set together, same rule as
|
|
// InvoicePaymentService::activateOrder()/activateRenewal().
|
|
'paid_at' => $invoiceData['paid_at'],
|
|
'client_ip' => request()->getClientIp(),
|
|
];
|
|
|
|
$order = DB::transaction(function () use ($invoiceData, $orderData, $productItems, $relIdData, $data, $orderTotal, $isBalancePayment, $effectiveInvoiceStatus) {
|
|
$invoice = $this->invoiceRepository->create($invoiceData);
|
|
|
|
$productItems = array_map(fn ($item) => array_merge($item, ['invoice_id' => $invoice->id]), $productItems);
|
|
|
|
if ($data['create_invoice'] ?? false) {
|
|
$orderData['invoice_id'] = $invoice->id;
|
|
}
|
|
|
|
$order = $this->orderRepository->create($orderData);
|
|
|
|
$invoice->update(['order_id' => $order->id]);
|
|
|
|
$relIdData = array_map(function ($item) use ($order) {
|
|
$item['data']['order_id'] = $order->id;
|
|
return $item;
|
|
}, $relIdData);
|
|
|
|
$this->createModules($relIdData);
|
|
|
|
$invoiceItemData = array_map(fn ($item) => [
|
|
'invoice_id' => $item['invoice_id'],
|
|
'product_id' => $item['product_id'],
|
|
'type' => 'service',
|
|
'months' => $item['months'],
|
|
'quantity' => $item['quantity'],
|
|
'unit_price' => $item['price'],
|
|
'total_price' => $item['price'] * $item['quantity'],
|
|
], $productItems);
|
|
$this->invoiceItemRepository->insert($invoiceItemData);
|
|
|
|
$now = now();
|
|
OrderItem::insert(array_map(fn ($item) => [
|
|
'order_id' => $order->id,
|
|
'product_id' => $item['product_id'],
|
|
'billing_cycle' => $item['billing_cycle'],
|
|
'months' => $item['months'],
|
|
'os_id' => $item['os_id'],
|
|
'quantity' => $item['quantity'],
|
|
'server_name' => $item['server_name'],
|
|
'price' => $item['price'],
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
], $productItems));
|
|
|
|
$vmTypes = [ProductTypeEnums::VPS, ProductTypeEnums::CLOUD];
|
|
foreach (OrderItem::where('order_id', $order->id)->get() as $orderItem) {
|
|
$type = ProductTypeEnums::tryFrom($orderItem->product?->category?->type ?? '');
|
|
if (in_array($type, $vmTypes)) {
|
|
CreateVirtualMachineJob::dispatch($orderItem)->delay(now()->addSeconds(10));
|
|
}
|
|
}
|
|
|
|
if ($isBalancePayment) {
|
|
$customer = Customer::lockForUpdate()->find($data['customer_id']);
|
|
|
|
if ($customer->total < $orderTotal) {
|
|
throw new \Exception('Số dư tài khoản không đủ để thanh toán đơn hàng.');
|
|
}
|
|
|
|
$customer->decrement('total', $orderTotal);
|
|
}
|
|
|
|
if ($effectiveInvoiceStatus === InvoiceStatus::PAID->value) {
|
|
Transaction::firstOrCreate(
|
|
[
|
|
'customer_id' => $data['customer_id'],
|
|
'trans' => $invoice->invoice_number,
|
|
'type' => 'invoice',
|
|
],
|
|
[
|
|
'uuid' => (string) Str::uuid(),
|
|
'public_code' => 'INV-PAY-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)),
|
|
'amount' => $orderTotal,
|
|
'user_id' => Auth::guard('admin')->id(),
|
|
'status' => TransactionStatus::COMPLETED->value,
|
|
'note' => 'Admin tạo đơn hàng và xác nhận thanh toán hóa đơn #' . $invoice->invoice_number,
|
|
]
|
|
);
|
|
}
|
|
|
|
return $order;
|
|
});
|
|
|
|
OrderCreatedJob::dispatch([
|
|
'customer_email' => $customer->email,
|
|
'client_name' => $customer->fullname,
|
|
'order_num' => $orderData['order_num'],
|
|
'invoice_num' => $invoiceData['invoice_number'],
|
|
'order_total' => number_format($orderTotal, 0, ',', '.'),
|
|
'company_name' => setting('general.company_name') ?: config('app.name'),
|
|
'company_logo' => setting('general.company_logo') ?: '',
|
|
]);
|
|
|
|
return $order;
|
|
}
|
|
|
|
private function buildProductItems(array $data): array
|
|
{
|
|
$total = 0;
|
|
$productItems = [];
|
|
$relIdData = [];
|
|
|
|
foreach ($data['product'] as $key => $item) {
|
|
$product = \App\Models\Product::with('category')->find($item['product_id']);
|
|
|
|
if (!$product) {
|
|
throw new \InvalidArgumentException("Sản phẩm không tồn tại (ID: {$item['product_id']}).");
|
|
}
|
|
|
|
$categoryType = ProductTypeEnums::tryFrom($product->category?->type ?? '');
|
|
$moduleType = match ($categoryType) {
|
|
ProductTypeEnums::HOSTING => 3,
|
|
ProductTypeEnums::VPS, ProductTypeEnums::CLOUD => 4,
|
|
default => 0,
|
|
};
|
|
|
|
$billingDetails = $this->calculateBillingDetails($item['billing']);
|
|
$priceSelect = $item['price_custom'] ?? $product->price_sale ?? $product->price;
|
|
|
|
$billingCycle = ProductBillingCycle::fromLegacyCode((int) $item['billing']);
|
|
$generalCode = Helper::generateUniqueCode();
|
|
|
|
$productItems[$key] = [
|
|
'product_id' => $product->id,
|
|
'product_type' => $categoryType?->value,
|
|
'months' => $billingCycle->months(),
|
|
'billing_cycle' => $billingCycle->value,
|
|
'os_id' => $item['os_id'] ?? null,
|
|
'quantity' => (int) ($item['quantity'] ?? 1),
|
|
'server_name' => $item['server_name'] ?? $item['domain'] ?? null,
|
|
'price' => (int) $priceSelect,
|
|
'invoice_type' => 1,
|
|
'code' => $generalCode,
|
|
];
|
|
|
|
$relIdData[$key] = [
|
|
'type' => $moduleType,
|
|
'code' => $generalCode,
|
|
'data' => [
|
|
'customer_id' => $data['customer_id'],
|
|
'status' => 0,
|
|
'name' => $item['server_name'] ?? $item['domain'] ?? $product->name,
|
|
'first_payment' => $priceSelect * $billingDetails['billing'],
|
|
'next_payment' => ($item['price_custom'] ?? $product->price) * $billingDetails['billing'],
|
|
...$billingDetails,
|
|
],
|
|
];
|
|
|
|
if ($item['quantity'] > 1) {
|
|
for ($i = 1; $i < $item['quantity']; $i++) {
|
|
$newCode = Helper::generateUniqueCode();
|
|
$productItems[] = array_merge($productItems[$key], ['code' => $newCode]);
|
|
$relIdData[] = array_merge($relIdData[$key], ['code' => $newCode]);
|
|
}
|
|
}
|
|
|
|
$total += $item['quantity'] * ($item['price_custom'] ?? ($product->price_sale ?? $product->price)) * $billingDetails['billing'];
|
|
}
|
|
|
|
return [$productItems, $relIdData, $total];
|
|
}
|
|
|
|
private function createModules(array $relIdData): void
|
|
{
|
|
foreach ($relIdData as $item) {
|
|
match ($item['type']) {
|
|
3 => $this->moduleHostingRepository->create($item['data']),
|
|
// type=4 (VPS): không pre-create ở đây.
|
|
// CreateVirtualMachineJob sẽ tạo module_vmware sau khi VM provisioned xong.
|
|
default => null,
|
|
};
|
|
}
|
|
}
|
|
|
|
private function calculateBillingDetails(int|string $billing): array
|
|
{
|
|
$months = match ((int) $billing) {
|
|
2 => 1,
|
|
3 => 3,
|
|
4 => 6,
|
|
5 => 12,
|
|
default => 0,
|
|
};
|
|
|
|
return [
|
|
'next_due_date' => $months ? Carbon::today()->addMonths($months)->timestamp : null,
|
|
'next_invoice' => $months ? Carbon::today()->addMonths($months)->timestamp : null,
|
|
'billing' => $months ?: 1,
|
|
];
|
|
}
|
|
}
|