- Mail campaign: mẫu nhắc gia hạn thanh toán QR, chọn dịch vụ + chu kỳ từng dòng (renewal_services) - Hosting: nhiều server Plesk cho một gói (service_plan_servers), chuyển server khi server chết - Admin xem/sửa giấy tờ định danh khách, tuỳ chọn gia hạn cho khách, nhật ký cấp phát dịch vụ - Đánh dấu mẫu email dịch vụ là non-marketing Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
290 lines
12 KiB
PHP
290 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\Admin\Customer\Concerns;
|
|
|
|
use App\Models\ActivityLog;
|
|
use App\Models\CustomProduct;
|
|
use App\Models\Invoice;
|
|
use App\Models\InvoiceAttachment;
|
|
use App\Models\Service;
|
|
use App\Services\Api\Order\OrderQueryService;
|
|
use App\Services\ServiceRenewalNotifier;
|
|
use App\Services\ServiceRenewalReminderService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Validation\Rule;
|
|
use App\Support\BillingCycleCatalog;
|
|
use App\Support\RenewalPeriodGuard;
|
|
use App\Support\ServiceProvisionLog;
|
|
|
|
trait ManagesCustomerServices
|
|
{
|
|
public function getServices(string $uuid): JsonResponse
|
|
{
|
|
return response()->json(['error' => false, 'data' => $this->serviceProvisioning->getServices($uuid)]);
|
|
}
|
|
|
|
public function attachService(string $uuid, Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate(['service_id' => 'required|integer|exists:services,id']);
|
|
|
|
$service = $this->serviceProvisioning->attachService($uuid, $data['service_id']);
|
|
|
|
return response()->json(['error' => false, 'message' => 'Đã gán dịch vụ cho khách hàng', 'data' => $service]);
|
|
}
|
|
|
|
public function bulkDeleteServices(string $uuid, Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate(['ids' => 'required|array|min:1', 'ids.*' => 'integer']);
|
|
|
|
$deleted = $this->serviceProvisioning->bulkDeleteServices($uuid, $data['ids']);
|
|
|
|
return response()->json(['error' => false, 'message' => "Đã gỡ {$deleted} dịch vụ khỏi khách hàng", 'data' => ['deleted' => $deleted]]);
|
|
}
|
|
|
|
public function updateService(string $uuid, int $serviceId, Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'server_name' => 'nullable|string|max:255',
|
|
'product_id' => 'nullable|integer|exists:products,id',
|
|
'status' => 'nullable|in:pending,active,suspended,expired,cancelled,terminated',
|
|
'auto_renew' => 'nullable|boolean',
|
|
'started_at' => 'nullable|date',
|
|
'expires_at' => 'nullable|date',
|
|
'note' => 'nullable|string',
|
|
'requirements' => 'nullable|string',
|
|
'feedback' => 'nullable|string',
|
|
'price' => 'nullable|numeric|min:0',
|
|
'billing_cycle' => ['nullable', Rule::in(BillingCycleCatalog::codes())],
|
|
'virtual_machine_id' => 'nullable|integer|exists:virtual_machines,id',
|
|
'os_id' => 'nullable|integer|exists:os,id',
|
|
'vm_cpu' => 'nullable|integer|min:1',
|
|
'vm_ram_mb' => 'nullable|integer|min:1',
|
|
'vm_disk_gb' => 'nullable|integer|min:1',
|
|
'vm_ip' => 'nullable|string|max:45',
|
|
'vm_dns' => 'nullable|string|max:100',
|
|
'vm_user' => 'nullable|string|max:255',
|
|
'vm_password' => 'nullable|string|max:255',
|
|
'vm_port' => 'nullable|integer|min:1|max:65535',
|
|
]);
|
|
|
|
$service = $this->serviceProvisioning->updateService($uuid, $serviceId, $data);
|
|
|
|
return response()->json(['error' => false, 'message' => 'Cập nhật dịch vụ thành công', 'data' => $service]);
|
|
}
|
|
|
|
public function changeServiceIp(string $uuid, int $serviceId, Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate(['ip' => 'required|ip']);
|
|
|
|
abort_if(!Service::where('id', $serviceId)->value('vm_id'), 422, 'Dịch vụ chưa gắn VM.');
|
|
|
|
$service = $this->serviceProvisioning->updateService($uuid, $serviceId, ['vm_ip' => $data['ip']]);
|
|
|
|
return response()->json(['error' => false, 'message' => 'Đã đổi IP thành công', 'data' => $service]);
|
|
}
|
|
|
|
public function changeServicePort(string $uuid, int $serviceId, Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate(['port' => 'required|integer|min:1|max:65535']);
|
|
|
|
abort_if(!Service::where('id', $serviceId)->value('vm_id'), 422, 'Dịch vụ chưa gắn VM.');
|
|
|
|
$service = $this->serviceProvisioning->updateService($uuid, $serviceId, ['vm_port' => $data['port']]);
|
|
|
|
return response()->json(['error' => false, 'message' => 'Đã đổi port thành công', 'data' => $service]);
|
|
}
|
|
|
|
public function notifyServiceRenewal(
|
|
string $uuid,
|
|
int $serviceId,
|
|
ServiceRenewalReminderService $reminders,
|
|
): JsonResponse
|
|
{
|
|
$customer = $this->resolveCustomer($uuid);
|
|
$service = Service::query()
|
|
->where('customer_id', $customer->id)
|
|
->where('id', $serviceId)
|
|
->first();
|
|
|
|
abort_if(! $service, 404, 'Không tìm thấy dịch vụ');
|
|
abort_if(! $customer->email, 422, 'Khách hàng chưa có email');
|
|
abort_if(! $service->expires_at, 422, 'Dịch vụ chưa có ngày hết hạn');
|
|
|
|
$reminders->send([$service->id], Auth::guard('admin')->user());
|
|
|
|
return response()->json([
|
|
'error' => false,
|
|
'message' => 'Đã đưa email nhắc gia hạn vào hàng đợi gửi và ghi nhận lịch sử.',
|
|
]);
|
|
}
|
|
|
|
public function serviceRenewals(string $uuid, int $serviceId): JsonResponse
|
|
{
|
|
$service = Service::where('customer_id', $this->resolveCustomer($uuid)->id)->findOrFail($serviceId);
|
|
|
|
$renewals = ActivityLog::where('subject_type', Service::class)
|
|
->where('subject_id', $service->id)
|
|
->where('event', 'renewed')
|
|
->latest('id')
|
|
->limit(100)
|
|
->get(['id', 'causer_type', 'causer_name', 'old_values', 'new_values', 'created_at']);
|
|
|
|
return response()->json(['error' => false, 'data' => $renewals]);
|
|
}
|
|
|
|
public function serviceProvisioningLogs(string $uuid, int $serviceId): JsonResponse
|
|
{
|
|
$service = Service::where('customer_id', $this->resolveCustomer($uuid)->id)->findOrFail($serviceId);
|
|
|
|
$logs = ActivityLog::where('subject_type', Service::class)
|
|
->where('subject_id', $service->id)
|
|
->whereIn('event', ServiceProvisionLog::EVENTS)
|
|
->orderBy('id')
|
|
->get(['id', 'event', 'description', 'causer_type', 'causer_name', 'new_values', 'created_at']);
|
|
|
|
return response()->json(['error' => false, 'data' => $logs]);
|
|
}
|
|
|
|
public function renewService(string $uuid, int $serviceId, Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'months' => 'required|integer|min:1|max:120',
|
|
'payment' => 'required|in:balance,manual',
|
|
'note' => 'nullable|string|max:500',
|
|
'proof' => ['nullable', ...array_slice(explode('|', InvoiceAttachment::UPLOAD_RULE), 1)],
|
|
]);
|
|
|
|
$customer = $this->resolveCustomer($uuid);
|
|
$current = Service::where('customer_id', $customer->id)->find($serviceId);
|
|
$previousExpiresAt = $current?->expires_at;
|
|
|
|
if ($current && $violation = RenewalPeriodGuard::violation($current, (int) $data['months'])) {
|
|
return response()->json(['error' => true, 'message' => $violation], 422);
|
|
}
|
|
|
|
$result = app(OrderQueryService::class)->renewServiceByAdmin(
|
|
$serviceId,
|
|
$customer,
|
|
(int) $data['months'],
|
|
$data['payment'],
|
|
(int) auth()->id(),
|
|
(string) ($data['note'] ?? ''),
|
|
);
|
|
|
|
if (! $result['ok']) {
|
|
return response()->json(
|
|
['error' => true, 'message' => $result['message'], 'data' => $result['data'] ?? null],
|
|
$result['code'],
|
|
);
|
|
}
|
|
|
|
$this->notifyRenewal($serviceId, (int) $result['data']['invoice_id'], (int) $data['months'], $previousExpiresAt, $data['payment']);
|
|
|
|
if ($data['payment'] === 'manual' && $request->hasFile('proof')) {
|
|
$this->storeRenewalProof($request->file('proof'), (int) $result['data']['invoice_id']);
|
|
}
|
|
|
|
return response()->json(['error' => false, 'message' => $result['message'], 'data' => $result['data']]);
|
|
}
|
|
|
|
private function notifyRenewal(int $serviceId, int $invoiceId, int $months, ?\Carbon\CarbonInterface $previousExpiresAt, string $payment): void
|
|
{
|
|
$admin = auth()->user();
|
|
|
|
app(ServiceRenewalNotifier::class)->notifyAdminRenewal(
|
|
Service::with(['customer', 'product'])->findOrFail($serviceId),
|
|
Invoice::findOrFail($invoiceId),
|
|
$months,
|
|
$previousExpiresAt,
|
|
$payment,
|
|
(string) ($admin?->fullname ?? $admin?->username ?? 'Admin'),
|
|
$admin?->id,
|
|
);
|
|
}
|
|
|
|
private function storeRenewalProof(UploadedFile $file, int $invoiceId): void
|
|
{
|
|
InvoiceAttachment::create([
|
|
'invoice_id' => $invoiceId,
|
|
'type' => InvoiceAttachment::TYPE_PAYMENT_PROOF,
|
|
'filename' => $file->getClientOriginalName(),
|
|
'path' => $file->store("invoice-attachments/{$invoiceId}", 'public'),
|
|
'disk' => 'public',
|
|
'mime_type' => $file->getMimeType(),
|
|
'size' => $file->getSize(),
|
|
'uploaded_by' => auth()->user()?->username ?? 'admin',
|
|
]);
|
|
}
|
|
|
|
public function createService(string $uuid, Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'product_id' => 'required_without:custom|nullable|integer|exists:products,id',
|
|
'custom' => 'nullable|array',
|
|
'custom.name' => 'required_with:custom|string|max:255',
|
|
'custom.category' => ['required_with:custom', Rule::in(CustomProduct::CATEGORIES)],
|
|
'custom.unit_price' => 'required_with:custom|numeric|min:0',
|
|
'custom.cpu' => 'nullable|integer|min:1',
|
|
'custom.ram_mb' => 'nullable|integer|min:1',
|
|
'custom.disk_gb' => 'nullable|integer|min:1',
|
|
'custom.bandwidth_gb' => 'nullable|integer|min:0',
|
|
'custom.description' => 'nullable|string|max:5000',
|
|
'custom.specs' => 'nullable|array|max:20',
|
|
'custom.specs.*.label' => 'required|string|max:100',
|
|
'custom.specs.*.value' => 'required|string|max:255',
|
|
'virtual_machine_id' => 'nullable|integer|exists:virtual_machines,id',
|
|
'server_name' => 'nullable|string|max:255',
|
|
'status' => 'nullable|in:pending,active,suspended,expired,cancelled,terminated',
|
|
'auto_renew' => 'nullable|boolean',
|
|
'started_at' => 'nullable|date',
|
|
'expires_at' => 'nullable|date',
|
|
'note' => 'nullable|string',
|
|
'billing_cycle' => ['nullable', Rule::in(BillingCycleCatalog::codes())],
|
|
'price' => 'nullable|numeric|min:0',
|
|
'payment_status' => 'nullable|in:paid,partial,unpaid',
|
|
'payment_method' => 'required_unless:payment_status,unpaid|nullable|in:transfer,balance',
|
|
'amount_paid' => 'required_if:payment_status,partial|nullable|numeric|gt:0',
|
|
'proof_images' => 'nullable|array',
|
|
'proof_images.*' => 'file|image|max:5120',
|
|
'os_id' => 'nullable|integer|exists:os,id',
|
|
'vm_cpu' => 'nullable|integer|min:1',
|
|
'vm_ram_mb' => 'nullable|integer|min:1',
|
|
'vm_disk_gb' => 'nullable|integer|min:1',
|
|
'vm_ip' => 'nullable|string|max:45',
|
|
'vm_dns' => 'nullable|string|max:100',
|
|
'vm_user' => 'nullable|string|max:255',
|
|
'vm_password' => 'nullable|string|max:255',
|
|
'vm_port' => 'nullable|integer|min:1|max:65535',
|
|
]);
|
|
|
|
$data['payment_status'] ??= 'paid';
|
|
if ($data['payment_status'] === 'unpaid') {
|
|
$data['payment_method'] = null;
|
|
}
|
|
|
|
if (($data['payment_method'] ?? null) === 'transfer' && $request->hasFile('proof_images')) {
|
|
$data['_proof_paths'] = collect($request->file('proof_images'))
|
|
->map(fn($file) => $file->store('payment-proofs', 'public'))
|
|
->all();
|
|
}
|
|
|
|
$service = $this->serviceProvisioning->createService($uuid, $data, $request->ip());
|
|
|
|
return response()->json(['error' => false, 'message' => 'Service created', 'data' => $service], 201);
|
|
}
|
|
|
|
public function listAvailableVms(Request $request): JsonResponse
|
|
{
|
|
$result = $this->serviceProvisioning->listAvailableVms(
|
|
trim((string) $request->get('q', '')),
|
|
max(1, (int) $request->get('page', 1)),
|
|
max(1, min((int) $request->get('per_page', 200), 200)),
|
|
);
|
|
|
|
return response()->json(['error' => false] + $result);
|
|
}
|
|
}
|