564 lines
22 KiB
PHP
564 lines
22 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\Service;
|
|
|
|
use App\Enums\Invoice\InvoiceStatus;
|
|
use App\Enums\Order\OrderStatus;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\Api\Order\PayOrderRequest;
|
|
use App\Http\Requests\Api\Order\StoreOrderRequest;
|
|
use App\Models\Invoice;
|
|
use App\Models\ModuleVmware;
|
|
use App\Models\Order;
|
|
use App\Models\Product;
|
|
use App\Models\Service;
|
|
use App\Repositories\Api\Order\OrderRepository;
|
|
use App\Services\Api\Order\OrderService;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
|
|
/**
|
|
* @OA\Tag(name="Orders", description="Order & Invoice management")
|
|
*/
|
|
class OrderController extends Controller
|
|
{
|
|
public function __construct(
|
|
protected OrderService $orderService,
|
|
protected OrderRepository $orderRepo,
|
|
) {}
|
|
|
|
// =========================================================================
|
|
// Orders
|
|
// =========================================================================
|
|
|
|
/**
|
|
* GET /v1/orders — paginated order list (admin / internal)
|
|
*/
|
|
public function getList(Request $request): JsonResponse
|
|
{
|
|
try {
|
|
$orders = $this->orderRepo->getOrders(
|
|
$request->user()->id,
|
|
$request->only(['total_price', 'pending'])
|
|
);
|
|
return response()->json(['success' => true, 'data' => $orders]);
|
|
} catch (\Exception $e) {
|
|
return $this->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /v1/client/orders — customer's order list
|
|
*/
|
|
public function getClientOrders(Request $request): JsonResponse
|
|
{
|
|
try {
|
|
$orders = Order::query()
|
|
->with(['invoice'])
|
|
->where('customer_id', $request->user()->id)
|
|
->orderByDesc('created_at')
|
|
->get();
|
|
|
|
$data = $orders->map(fn(Order $order) => [
|
|
'id' => $order->id,
|
|
'order_num' => $order->order_num,
|
|
'status' => $order->status,
|
|
'status_text' => OrderStatus::from((int) $order->status)->label(),
|
|
'total' => (float) $order->total,
|
|
'created_at' => $this->isoDate($order->created_at),
|
|
'invoice' => $order->invoice ? [
|
|
'id' => $order->invoice->id,
|
|
'invoice_number' => $order->invoice->invoice_number,
|
|
'status' => $order->invoice->status,
|
|
'total' => $order->invoice->total,
|
|
] : null,
|
|
]);
|
|
|
|
return response()->json(['success' => true, 'data' => $data]);
|
|
} catch (\Exception $e) {
|
|
return $this->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /v1/client/orders/{id} — order detail
|
|
*/
|
|
public function getClientOrderDetail(Request $request, int $id): JsonResponse
|
|
{
|
|
try {
|
|
$order = Order::query()
|
|
->with(['invoice', 'orderItems.product', 'customer'])
|
|
->where('customer_id', $request->user()->id)
|
|
->where('id', $id)
|
|
->first();
|
|
|
|
if (!$order) {
|
|
return $this->notFound('Đơn hàng không tồn tại.');
|
|
}
|
|
|
|
return response()->json(['success' => true, 'data' => $this->formatOrderDetail($order)]);
|
|
} catch (\Exception $e) {
|
|
return $this->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /v1/orders — create a new order
|
|
*
|
|
* @OA\Post(path="/orders", operationId="storeOrder", tags={"Orders"},
|
|
* security={{"bearerAuth":{}}},
|
|
* @OA\RequestBody(required=true, @OA\JsonContent(
|
|
* required={"product_id","billing_cycle"},
|
|
* @OA\Property(property="product_id", type="string", example="uuid-here"),
|
|
* @OA\Property(property="billing_cycle", type="string", example="annually",
|
|
* enum={"monthly","quarterly","semi_annually","annually","biennially"}),
|
|
* @OA\Property(property="server_name", type="string", example="my-vps-01"),
|
|
* @OA\Property(property="os_id", type="integer", example=1),
|
|
* @OA\Property(property="addons", type="array", @OA\Items(
|
|
* @OA\Property(property="addon_id", type="integer", example=1),
|
|
* @OA\Property(property="qty", type="integer", example=2)
|
|
* ))
|
|
* )),
|
|
* @OA\Response(response=201, description="Order created successfully")
|
|
* )
|
|
*/
|
|
public function storeOrder(StoreOrderRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$customer = $request->user();
|
|
$data = $request->validated();
|
|
|
|
$product = Product::where('uuid', $data['product_id'])->first();
|
|
if (!$product) {
|
|
throw new \Exception('Sản phẩm không tìm thấy hoặc đã bị xóa.');
|
|
}
|
|
$data['product_id'] = $product->id;
|
|
|
|
$result = $this->orderService->create($data, $customer);
|
|
|
|
$order = $result['order']->load(['orderItems.product', 'invoice', 'customer']);
|
|
$invoice = $result['invoice'];
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Tạo đơn hàng thành công.',
|
|
'notice' => 'Dịch vụ VPS sẽ được khởi tạo trong 5-10 phút sau khi thanh toán.',
|
|
'data' => [
|
|
'order' => $this->formatOrderSummary($order),
|
|
'invoice' => $this->formatInvoiceSummary($invoice),
|
|
'items' => $this->formatOrderItems($order->orderItems),
|
|
],
|
|
], 201);
|
|
} catch (\Exception $e) {
|
|
return $this->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /v1/orders/{id}/pay — pay for a pending order
|
|
*
|
|
* @OA\Post(path="/orders/{id}/pay", operationId="payOrder", tags={"Orders"},
|
|
* security={{"bearerAuth":{}}},
|
|
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
|
* @OA\RequestBody(required=true, @OA\JsonContent(
|
|
* required={"payment_method"},
|
|
* @OA\Property(property="payment_method", type="string",
|
|
* enum={"credit","bank_transfer","vnpay","momo"})
|
|
* )),
|
|
* @OA\Response(response=200, description="Payment successful")
|
|
* )
|
|
*/
|
|
public function payOrder(int $id, PayOrderRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$order = $this->orderRepo->find($id);
|
|
|
|
if (!$order) {
|
|
return $this->notFound('Đơn hàng không tồn tại.');
|
|
}
|
|
if ($order->customer_id !== $request->user()->id) {
|
|
return response()->json(['success' => false, 'message' => 'Không có quyền truy cập.'], 403);
|
|
}
|
|
|
|
$this->orderService->pay($order, $request->payment_method);
|
|
|
|
return response()->json(['success' => true, 'message' => 'Thanh toán thành công.']);
|
|
} catch (\Exception $e) {
|
|
return $this->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Invoices
|
|
// =========================================================================
|
|
|
|
/**
|
|
* GET /v1/client/invoices — customer's invoice list
|
|
*/
|
|
public function getClientInvoices(Request $request): JsonResponse
|
|
{
|
|
try {
|
|
$invoices = Invoice::query()
|
|
->with(['order.orderItems.product'])
|
|
->where('customer_id', $request->user()->id)
|
|
->orderByDesc('created_at')
|
|
->get();
|
|
|
|
$data = $invoices->map(fn(Invoice $invoice) => [
|
|
'id' => $invoice->id,
|
|
'invoice_number' => $invoice->invoice_number,
|
|
'service_name' => $invoice->order?->orderItems->first()?->product?->name
|
|
?? ('Đơn hàng ' . $invoice->order?->order_num),
|
|
'total' => (float) $invoice->total,
|
|
'status' => $invoice->status,
|
|
'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-',
|
|
'created_at' => $this->isoDate($invoice->created_at),
|
|
'order_id' => $invoice->order?->id,
|
|
'order_num' => $invoice->order?->order_num,
|
|
])->values();
|
|
|
|
return response()->json(['success' => true, 'data' => $data]);
|
|
} catch (\Exception $e) {
|
|
return $this->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /v1/client/invoices/{id} — invoice detail
|
|
*/
|
|
public function getClientInvoiceDetail(Request $request, int $id): JsonResponse
|
|
{
|
|
try {
|
|
$invoice = Invoice::query()
|
|
->with([
|
|
'order.orderItems.product',
|
|
'order.vmWare.ipAddress',
|
|
'order.vmWare.os',
|
|
'invoiceItems',
|
|
'customer',
|
|
'attachments',
|
|
])
|
|
->where('customer_id', $request->user()->id)
|
|
->where('id', $id)
|
|
->first();
|
|
|
|
if (!$invoice) {
|
|
return $this->notFound('Hóa đơn không tồn tại.');
|
|
}
|
|
|
|
return response()->json(['success' => true, 'data' => $this->formatInvoiceDetail($invoice)]);
|
|
} catch (\Exception $e) {
|
|
return $this->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /v1/client/invoices/order/{orderNumber} — invoice detail by order number
|
|
*/
|
|
public function getClientInvoiceDetailByOrderNum(Request $request, string $orderNumber): JsonResponse
|
|
{
|
|
try {
|
|
$order = Order::query()
|
|
->with([
|
|
'invoice.invoiceItems',
|
|
'invoice.attachments',
|
|
'invoice.customer',
|
|
'orderItems.product',
|
|
'customer',
|
|
'vmWare.ipAddress',
|
|
'vmWare.os',
|
|
])
|
|
->where('customer_id', $request->user()->id)
|
|
->where('order_num', $orderNumber)
|
|
->first();
|
|
|
|
if (!$order?->invoice) {
|
|
return $this->notFound('Hóa đơn không tồn tại.');
|
|
}
|
|
|
|
// Reuse formatInvoiceDetail with already-loaded relations
|
|
$invoice = $order->invoice;
|
|
// Attach order + vmWare so formatter can use them
|
|
$invoice->setRelation('order', $order);
|
|
$invoice->setRelation('customer', $order->customer);
|
|
|
|
return response()->json(['success' => true, 'data' => $this->formatInvoiceDetail($invoice)]);
|
|
} catch (\Exception $e) {
|
|
return $this->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Services (dich-vu)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* GET /v1/client/dich-vu — list of customer's services
|
|
*/
|
|
public function getClientServices(Request $request): JsonResponse
|
|
{
|
|
try {
|
|
$services = Service::query()
|
|
->with(['product', 'addons', 'invoice', 'orderItem'])
|
|
->where('customer_id', $request->user()->id)
|
|
->orderByDesc('created_at')
|
|
->get();
|
|
|
|
$data = $services->map(function (Service $service) {
|
|
$vmware = $service->vm_id
|
|
? ModuleVmware::with(['ipAddress', 'os'])->find((int) $service->vm_id)
|
|
: null;
|
|
|
|
return [
|
|
'id' => $service->id,
|
|
'service_name' => $service->product?->name ?? $service->server_name,
|
|
'server_name' => $service->server_name,
|
|
'product_type' => $service->product?->product_type,
|
|
'billing_cycle' => $service->orderItem?->billing_cycle,
|
|
'status' => $service->status,
|
|
'status_text' => $service->status_label,
|
|
'auto_renew' => (bool) $service->auto_renew,
|
|
'started_at' => $this->isoDate($service->started_at),
|
|
'expires_at' => $this->isoDate($service->expires_at),
|
|
'invoice_id' => $service->invoice_id,
|
|
'invoice_number' => $service->invoice?->invoice_number,
|
|
'addons' => $service->addons->map(fn($a) => [
|
|
'type' => $a->addon_type,
|
|
'value' => $a->addon_value,
|
|
'price' => $a->price,
|
|
])->all(),
|
|
'vmware' => $vmware ? [
|
|
'id' => $vmware->id,
|
|
'name' => $vmware->name,
|
|
'hostname' => $vmware->hostname,
|
|
'ip_address' => $vmware->ipAddress?->ip,
|
|
'os_name' => $vmware->os?->name,
|
|
'status' => $vmware->status,
|
|
] : null,
|
|
];
|
|
})->values();
|
|
|
|
return response()->json(['success' => true, 'data' => $data]);
|
|
} catch (\Exception $e) {
|
|
return $this->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PATCH /v1/client/dich-vu/{id}/auto-renew — toggle auto-renewal on a service
|
|
*/
|
|
public function updateClientServiceAutoRenew(Request $request, int $id): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validate(['auto_renew' => ['required', 'boolean']]);
|
|
|
|
$service = Service::query()
|
|
->where('id', $id)
|
|
->where('customer_id', $request->user()->id)
|
|
->first();
|
|
|
|
if (!$service) {
|
|
return $this->notFound('Không tìm thấy dịch vụ.');
|
|
}
|
|
|
|
$service->update(['auto_renew' => (bool) $validated['auto_renew']]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Cập nhật tự động gia hạn thành công.',
|
|
'data' => ['id' => $service->id, 'auto_renew' => (bool) $service->auto_renew],
|
|
]);
|
|
} catch (\Exception $e) {
|
|
return $this->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Private — formatters
|
|
// =========================================================================
|
|
|
|
private function formatOrderSummary(Order $order): array
|
|
{
|
|
return [
|
|
'id' => $order->id,
|
|
'order_num' => $order->order_num,
|
|
'status' => $order->status,
|
|
'status_text' => OrderStatus::from((int) $order->status)->label(),
|
|
'sub_total' => $order->sub_total,
|
|
'total' => $order->total,
|
|
'created_at' => $this->isoDate($order->created_at),
|
|
];
|
|
}
|
|
|
|
private function formatInvoiceSummary(Invoice $invoice): array
|
|
{
|
|
return [
|
|
'id' => $invoice->id,
|
|
'invoice_number' => $invoice->invoice_number,
|
|
'sub_total' => $invoice->sub_total,
|
|
'tax' => $invoice->tax,
|
|
'total' => $invoice->total,
|
|
'status' => $invoice->status,
|
|
'due_date' => $this->isoDate($invoice->due_date),
|
|
];
|
|
}
|
|
|
|
private function formatOrderItems($items): array
|
|
{
|
|
return $items->map(fn($item) => [
|
|
'id' => $item->id,
|
|
'billing_cycle' => $item->billing_cycle,
|
|
'months' => $item->months,
|
|
'server_name' => $item->server_name,
|
|
'os_id' => $item->os_id,
|
|
'price' => (float) $item->price,
|
|
'product' => $item->product ? [
|
|
'id' => $item->product->id,
|
|
'uuid' => $item->product->uuid,
|
|
'name' => $item->product->name,
|
|
'product_type' => $item->product->product_type,
|
|
'specs' => [
|
|
'cpu' => $item->product->cpu,
|
|
'ram' => $item->product->ram,
|
|
'disk' => $item->product->disk,
|
|
'bandwidth' => $item->product->bandwidth,
|
|
],
|
|
] : null,
|
|
])->all();
|
|
}
|
|
|
|
private function formatOrderDetail(Order $order): array
|
|
{
|
|
$invoice = $order->invoice;
|
|
|
|
return [
|
|
'id' => $order->id,
|
|
'order_num' => $order->order_num,
|
|
'status' => $order->status,
|
|
'status_text' => OrderStatus::from((int) $order->status)->label(),
|
|
'total' => (float) $order->total,
|
|
'created_at' => $this->isoDate($order->created_at),
|
|
'customer' => [
|
|
'id' => $order->customer?->id,
|
|
'name' => $order->customer?->fullname,
|
|
'email' => $order->customer?->email,
|
|
],
|
|
'invoice' => $invoice ? [
|
|
'id' => $invoice->id,
|
|
'invoice_number' => $invoice->invoice_number,
|
|
'status' => $invoice->status,
|
|
'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-',
|
|
'sub_total' => $invoice->sub_total,
|
|
'tax' => $invoice->tax,
|
|
'total' => $invoice->total,
|
|
'due_date' => $this->isoDate($invoice->due_date),
|
|
'paid_at' => $this->isoDate($invoice->paid_at),
|
|
] : null,
|
|
'items' => $this->formatOrderItems($order->orderItems),
|
|
];
|
|
}
|
|
|
|
private function formatInvoiceDetail(Invoice $invoice): array
|
|
{
|
|
$order = $invoice->order;
|
|
$vmware = $order?->vmWare->first();
|
|
|
|
return [
|
|
'id' => $invoice->id,
|
|
'invoice_number' => $invoice->invoice_number,
|
|
'invoice_type' => $invoice->invoice_type,
|
|
'status' => $invoice->status,
|
|
'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-',
|
|
'sub_total' => (float) $invoice->sub_total,
|
|
'discount_amount' => (float) ($invoice->discount_amount ?? 0),
|
|
'tax' => (float) $invoice->tax,
|
|
'total' => (float) $invoice->total,
|
|
'payment_method' => $invoice->payment_method,
|
|
'auto_renew' => (bool) $invoice->auto_renew,
|
|
'created_at' => $this->isoDate($invoice->created_at),
|
|
'due_date' => $this->isoDate($invoice->due_date),
|
|
'paid_at' => $this->isoDate($invoice->paid_at),
|
|
'customer' => $invoice->customer ? [
|
|
'id' => $invoice->customer->id,
|
|
'name' => $invoice->customer->fullname,
|
|
'email' => $invoice->customer->email,
|
|
'address' => $invoice->customer->address,
|
|
] : null,
|
|
'order' => $order ? [
|
|
'id' => $order->id,
|
|
'order_num' => $order->order_num,
|
|
'total' => (float) $order->total,
|
|
] : null,
|
|
'service' => ($firstItem = $order?->orderItems->first()) ? [
|
|
'product_id' => $firstItem->product?->id,
|
|
'product_uuid' => $firstItem->product?->uuid,
|
|
'name' => $firstItem->product?->name,
|
|
'billing_cycle' => $firstItem->billing_cycle,
|
|
'server_name' => $firstItem->server_name,
|
|
'specs' => [
|
|
'cpu' => $firstItem->product?->cpu,
|
|
'ram' => $firstItem->product?->ram,
|
|
'disk' => $firstItem->product?->disk,
|
|
],
|
|
] : null,
|
|
'items' => ($invoice->relationLoaded('invoiceItems') ? $invoice->invoiceItems : collect())->map(fn($item) => [
|
|
'id' => $item->id,
|
|
'type' => $item->type,
|
|
'description' => $item->description,
|
|
'months' => $item->months,
|
|
'quantity' => $item->quantity,
|
|
'unit_price' => (float) $item->unit_price,
|
|
'total_price' => (float) $item->total_price,
|
|
])->all(),
|
|
'attachments' => ($invoice->relationLoaded('attachments') ? $invoice->attachments : collect())->map(fn($a) => [
|
|
'id' => $a->id,
|
|
'filename' => $a->filename,
|
|
'url' => $a->url,
|
|
'mime_type' => $a->mime_type,
|
|
])->all(),
|
|
'vmware' => $vmware ? [
|
|
'id' => $vmware->id,
|
|
'name' => $vmware->name,
|
|
'hostname' => $vmware->hostname,
|
|
'ip_address' => $vmware->ipAddress?->ip,
|
|
'os_name' => $vmware->os?->name,
|
|
'status' => $vmware->status,
|
|
'next_due_date' => $this->isoDate($vmware->next_due_date),
|
|
] : null,
|
|
];
|
|
}
|
|
|
|
// =========================================================================
|
|
// Micro-helpers
|
|
// =========================================================================
|
|
|
|
private function isoDate($value): ?string
|
|
{
|
|
if (empty($value)) {
|
|
return null;
|
|
}
|
|
try {
|
|
if ($value instanceof \DateTimeInterface) {
|
|
return Carbon::instance($value)->toISOString();
|
|
}
|
|
if (is_numeric($value)) {
|
|
return Carbon::createFromTimestamp((int) $value)->toISOString();
|
|
}
|
|
return Carbon::parse($value)->toISOString();
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function error(string $message, int $code = 400): JsonResponse
|
|
{
|
|
return response()->json(['success' => false, 'message' => $message], $code);
|
|
}
|
|
|
|
private function notFound(string $message): JsonResponse
|
|
{
|
|
return response()->json(['success' => false, 'message' => $message], 404);
|
|
}
|
|
}
|