feat(service): huỷ dịch vụ, SSL, đổi mật khẩu hosting, lịch sử thanh toán theo hình thức
- Lịch sử thanh toán hoá đơn trả thêm `method` (tự động gia hạn / số dư / admin / chuyển khoản / hoàn tiền), bỏ đuôi `(method)` khỏi ghi chú - `DnsServer::panelUrl()` = https://<domain>/ dùng cho email cấp hosting và nút Plesk của khách - Order item trả thêm `uuid`/`type` của dịch vụ để FE dẫn tới trang quản lý - Yêu cầu huỷ dịch vụ, chứng chỉ SSL, lịch sử gia hạn, đổi mật khẩu hosting cho khách Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
a6b641015e
commit
b44a88bb52
@@ -30,7 +30,7 @@ class ProductController extends Controller
|
||||
{
|
||||
$perPage = min((int) $request->get('per_page', 15), 200);
|
||||
$paginator = $this->products->paginate(
|
||||
$request->only('filter', 'type', 'is_active'),
|
||||
$request->only('filter', 'type', 'is_active', 'product_categories_id'),
|
||||
$perPage
|
||||
);
|
||||
|
||||
|
||||
@@ -28,11 +28,15 @@ trait HandlesServiceAttachments
|
||||
public function uploadAttachment(Request $request, int $id): JsonResponse
|
||||
{
|
||||
Service::findOrFail($id);
|
||||
$request->validate(['file' => ServiceAttachment::UPLOAD_RULE]);
|
||||
$audience = $request->validate([
|
||||
'file' => ServiceAttachment::UPLOAD_RULE,
|
||||
'audience' => 'nullable|in:' . ServiceAttachment::AUDIENCE_ADMIN . ',' . ServiceAttachment::AUDIENCE_CUSTOMER,
|
||||
])['audience'] ?? ServiceAttachment::AUDIENCE_ADMIN;
|
||||
|
||||
$file = $request->file('file');
|
||||
$attachment = ServiceAttachment::create([
|
||||
'service_id' => $id,
|
||||
'audience' => $audience,
|
||||
'filename' => $file->getClientOriginalName(),
|
||||
'path' => $file->store("service-attachments/{$id}", 'public'),
|
||||
'disk' => 'public',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Service;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceCancellationRequest;
|
||||
use App\Support\ServiceCancellation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ServiceCancellationRequestController extends Controller
|
||||
{
|
||||
public function index(int $id): JsonResponse
|
||||
{
|
||||
$requests = ServiceCancellationRequest::where('service_id', $id)
|
||||
->latest('id')
|
||||
->limit(20)
|
||||
->get(['id', 'reason_code', 'reason', 'status', 'admin_note', 'resolved_at', 'resolved_by', 'created_at']);
|
||||
|
||||
return response()->json(['error' => false, 'data' => $requests]);
|
||||
}
|
||||
|
||||
public function resolve(Request $request, int $id, int $requestId): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'action' => 'required|in:approve,reject',
|
||||
'note' => 'required_if:action,reject|nullable|string|max:1000',
|
||||
]);
|
||||
$approve = $data['action'] === 'approve';
|
||||
|
||||
$result = DB::transaction(function () use ($id, $requestId, $data, $approve, $request) {
|
||||
$cancellation = ServiceCancellationRequest::where('service_id', $id)->lockForUpdate()->findOrFail($requestId);
|
||||
if ($cancellation->status !== ServiceCancellationRequest::STATUS_PENDING) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$admin = $request->user();
|
||||
$cancellation->update([
|
||||
'status' => $approve ? ServiceCancellationRequest::STATUS_APPROVED : ServiceCancellationRequest::STATUS_REJECTED,
|
||||
'admin_note' => filled($data['note'] ?? null) ? trim($data['note']) : null,
|
||||
'resolved_at' => now(),
|
||||
'resolved_by' => (string) ($admin?->fullname ?: $admin?->username ?: 'admin'),
|
||||
]);
|
||||
|
||||
$outcome = $approve ? ServiceCancellation::approve(Service::with('orderItem')->findOrFail($id)) : null;
|
||||
|
||||
return [$cancellation, $outcome];
|
||||
});
|
||||
|
||||
[$result, $outcome] = $result ?? [null, null];
|
||||
|
||||
if ($result === null) {
|
||||
return response()->json(['error' => true, 'message' => 'Yêu cầu này đã được xử lý.'], 422);
|
||||
}
|
||||
|
||||
$this->notifyCustomer($result, $approve, $outcome);
|
||||
|
||||
$message = match (true) {
|
||||
! $approve => 'Đã từ chối yêu cầu hủy.',
|
||||
$outcome === ServiceCancellation::SCHEDULED => 'Đã duyệt: dịch vụ dùng tới hết hạn rồi tự hủy.',
|
||||
default => 'Đã duyệt và hủy dịch vụ.',
|
||||
};
|
||||
|
||||
return response()->json(['error' => false, 'message' => $message, 'data' => $result]);
|
||||
}
|
||||
|
||||
private function notifyCustomer(ServiceCancellationRequest $cancellation, bool $approve, ?string $outcome): void
|
||||
{
|
||||
$cancellation->loadMissing(['customer', 'service']);
|
||||
if (! $cancellation->customer) {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $cancellation->service->server_name ?: 'dịch vụ của bạn';
|
||||
$expiresAt = $cancellation->service->expires_at?->format('d/m/Y');
|
||||
$approvedBody = $outcome === ServiceCancellation::SCHEDULED
|
||||
? "Dịch vụ {$name} sẽ không gia hạn nữa và vẫn sử dụng được đến hết {$expiresAt}."
|
||||
: "Dịch vụ {$name} đã được hủy theo yêu cầu của bạn.";
|
||||
$reason = $cancellation->admin_note ? " Ghi chú: {$cancellation->admin_note}" : '';
|
||||
|
||||
Notification::notifyCustomer($cancellation->customer, [
|
||||
'type' => 'service_cancellation_resolved',
|
||||
'title' => $approve ? 'Yêu cầu hủy dịch vụ đã được duyệt' : 'Yêu cầu hủy dịch vụ bị từ chối',
|
||||
'body' => ($approve ? $approvedBody : "Yêu cầu hủy dịch vụ {$name} chưa được chấp nhận.") . $reason,
|
||||
'link' => '/dich-vu',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class ServiceController extends Controller
|
||||
$perPage = min((int) ($request->per_page ?? 15), 100);
|
||||
|
||||
$query = Service::with(['customer:id,uuid,fullname,email', 'product:id,uuid,name,product_type', 'invoice:id,invoice_number', 'vmware:id,name,module_ip_address', 'orderItem:id,order_id', 'orderItem.order:id,order_num', 'tags'])
|
||||
->withExists(['cancellationRequests as pending_cancellation' => fn ($q) => $q->pending()])
|
||||
->marked($request->all())
|
||||
->where(fn($q) => $q->whereNull('order_item_id')->orWhereNull('type')->orWhere('type', '!=', 'domain'))
|
||||
->when($request->filter, fn($q, $v) => $q->where('server_name', 'like', "%{$v}%"))
|
||||
@@ -93,6 +94,7 @@ class ServiceController extends Controller
|
||||
->findOrFail($id);
|
||||
|
||||
ModuleVmware::resolveVirtualMachineIds([$service->vmware]);
|
||||
\App\Support\HostingCredentials::attach([$service]);
|
||||
|
||||
$invoices = $this->linkedInvoices(collect([$service]));
|
||||
$payments = InvoicePaymentHistory::forInvoices($invoices);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Service;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceSslCertificate;
|
||||
use App\Support\ServiceGroup;
|
||||
use App\Support\SslBundleExtractor;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class SslCertificateController extends Controller
|
||||
{
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return $this->respond($this->sslService($id)->sslCertificate?->data);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$service = $this->sslService($id);
|
||||
$max = ServiceSslCertificate::MAX_ENTRIES;
|
||||
|
||||
$data = $request->validate([
|
||||
'fields' => "present|array|max:{$max}",
|
||||
'fields.*.label' => 'required|string|max:100',
|
||||
'fields.*.value' => 'nullable|string|max:5000',
|
||||
'files' => "present|array|max:{$max}",
|
||||
'files.*.name' => 'required|string|max:150',
|
||||
'files.*.content' => 'required|string|max:' . ServiceSslCertificate::MAX_FILE_BYTES,
|
||||
]);
|
||||
|
||||
return $this->respond($this->store($service, [
|
||||
'fields' => array_map(fn ($field) => ['label' => $field['label'], 'value' => (string) ($field['value'] ?? '')], $data['fields']),
|
||||
'files' => array_map(fn ($file) => ['name' => basename($file['name']), 'content' => $file['content']], $data['files']),
|
||||
]));
|
||||
}
|
||||
|
||||
public function uploadArchive(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$service = $this->sslService($id);
|
||||
$request->validate(['archive' => 'required|file|mimes:zip|max:10240']);
|
||||
|
||||
try {
|
||||
$extracted = SslBundleExtractor::extract($request->file('archive'));
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
return response()->json(['error' => true, 'message' => $exception->getMessage()], 422);
|
||||
}
|
||||
|
||||
$current = $service->sslCertificate?->data ?? ServiceSslCertificate::emptyData();
|
||||
$byName = collect($current['files'])->keyBy('name');
|
||||
foreach ($extracted as $file) {
|
||||
$byName[$file['name']] = $file;
|
||||
}
|
||||
|
||||
if ($byName->count() > ServiceSslCertificate::MAX_ENTRIES) {
|
||||
return response()->json(['error' => true, 'message' => 'Quá nhiều tệp chứng chỉ, hãy xoá bớt trước khi tải thêm.'], 422);
|
||||
}
|
||||
|
||||
return $this->respond($this->store($service, ['fields' => $current['fields'], 'files' => $byName->values()->all()]));
|
||||
}
|
||||
|
||||
private function sslService(int $id): Service
|
||||
{
|
||||
$service = Service::with(['product:id,name,product_type', 'sslCertificate'])->findOrFail($id);
|
||||
abort_unless(ServiceGroup::of($service->product?->product_type ?? $service->type, $service->product?->name) === ServiceGroup::SSL, 422, 'Dịch vụ này không phải SSL.');
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
private function store(Service $service, array $data): array
|
||||
{
|
||||
ServiceSslCertificate::updateOrCreate(['service_id' => $service->id], ['data' => $data]);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function respond(?array $data): JsonResponse
|
||||
{
|
||||
return response()->json(['error' => false, 'data' => $data ?? ServiceSslCertificate::emptyData()]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user