Files
portal-api/app/Services/OrderService.php
T

387 lines
16 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\DomainPricing;
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 App\Support\AutoEmailGate;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Support\VatNumbering;
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(),
'date' => Carbon::today()->toDateString(),
'customer_id' => $data['customer_id'],
'discount_id' => $data['discount_id'] ?? null,
'sub_total' => $total,
'total' => $orderTotal,
// `payment` has no `code` column. Store its primary key in the
// legacy string column so the selected method can be resolved
// consistently on both orders and invoices.
'payment_method' => $paymentRecord?->id,
'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);
VatNumbering::settle($invoice);
$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' => Transaction::newPublicCode('INV-PAY'),
'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;
});
if (AutoEmailGate::allows('order_created')) {
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']);
$isDomain = $categoryType === ProductTypeEnums::DOMAIN;
$billingMultiplier = $isDomain ? 1 : $billingDetails['billing'];
if ($isDomain) {
$domainName = strtolower(trim((string) ($item['server_name'] ?? $item['domain'] ?? '')));
if ($domainName === '') {
throw new \InvalidArgumentException('Vui lòng nhập đầy đủ tên miền.');
}
$domainPrice = DomainPricing::findByDomain($domainName)->orderPrice(1);
$priceSelect = $item['price_custom'] ?? $domainPrice;
} else {
$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 * $billingMultiplier,
'next_payment' => $priceSelect * $billingMultiplier,
...$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'] * $priceSelect * $billingMultiplier;
}
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,
];
}
}