From 3ed33ea7e4ee8206d4566d3e1e0e0bc5fb187d8d Mon Sep 17 00:00:00 2001 From: baodev0 Date: Fri, 24 Jul 2026 09:29:43 +0700 Subject: [PATCH] Refactor VM creation and configuration process - Updated VpsServiceResource to return product UUID instead of ID. - Modified VirtualMachineService to utilize product data for CPU, RAM, and disk settings. - Replaced AssignIpToGuestJob with UpdateVmConfigJob in CreateVirtualMachineJob to streamline VM configuration after cloning. - Adjusted AssignIpToGuestJob to remove unnecessary UpdateVmConfigJob dispatch and focus on IP assignment. - Enhanced UpdateVmConfigJob to handle CPU/RAM configuration and power cycling more effectively. - Introduced ChangeIpVMJob for handling customer-initiated IP changes asynchronously. - Updated InvoiceRepository to set invoice due date based on maximum months instead of a fixed 7 days. - Improved web design quote PDF layout for better readability. - Added Times-Roman font metrics for PDF generation. --- .../Controllers/Api/Service/VmController.php | 89 +-- .../Api/Service/VpsServiceResource.php | 4 +- .../vCenter/VirtualMachineService.php | 7 +- app/Jobs/VirtualMachine/ChangeIpVMJob.php | 120 ++++ .../Create/CreateVirtualMachineJob.php | 33 +- .../Provision/AssignIpToGuestJob.php | 11 +- .../Provision/UpdateVmConfigJob.php | 49 +- .../Api/Invoice/InvoiceRepository.php | 2 +- .../views/pdf/web-design-quote.blade.php | 10 +- storage/fonts/Times-Roman.afm.json | 569 ++++++++++++++++++ 10 files changed, 767 insertions(+), 127 deletions(-) create mode 100644 app/Jobs/VirtualMachine/ChangeIpVMJob.php create mode 100644 storage/fonts/Times-Roman.afm.json diff --git a/app/Http/Controllers/Api/Service/VmController.php b/app/Http/Controllers/Api/Service/VmController.php index 16cada2..4c3c8e2 100644 --- a/app/Http/Controllers/Api/Service/VmController.php +++ b/app/Http/Controllers/Api/Service/VmController.php @@ -2,14 +2,13 @@ namespace App\Http\Controllers\Api\Service; -use App\Actions\VM\ChangeIpVMAction; use App\Actions\VM\ChangePortVMAction; use App\Actions\VM\PowerOffVMAction; use App\Actions\VM\PowerOnVMAction; use App\Http\Controllers\Api\Service\Concerns\ResolvesVm; use App\Http\Controllers\Controller; -use App\Http\Services\Telegram; use App\Http\Services\vCenter; +use App\Jobs\VirtualMachine\ChangeIpVMJob; use App\Jobs\VirtualMachine\ChangeVmPasswordJob; use App\Models\ModuleVmware; use App\Models\Os; @@ -19,7 +18,6 @@ use App\Services\VmProvisionService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Log; class VmController extends Controller { @@ -124,7 +122,10 @@ class VmController extends Controller * ip_address string New IP to assign * gateway string (optional) Gateway — falls back to existing IP record */ - public function changeIp(Request $request, ChangeIpVMAction $action): JsonResponse + /** + * Dispatches ChangeIpVMJob — returns immediately, runs async. + */ + public function changeIp(Request $request): JsonResponse { $validated = $request->validate([ 'server_name' => ['required', 'string', 'max:255'], @@ -165,87 +166,35 @@ class VmController extends Controller : 'windows'; $gateway = $validated['gateway'] ?? ''; + $dns = $validated['dns'] ?? '8.8.8.8'; - $dns = $validated['dns'] ?? '8.8.8.8'; - - $lock = $this->acquireVmLock($vm, 120); + // ponytail: don't release — lock expires after 5 min while the async job runs + $lock = $this->acquireVmLock($vm, 300); if (!$lock) { return $this->vmLocked(); } - try { - $raw = $action->execute( - $vm, - $validated['ip_address'], - $gateway, - $osType, - $dns, - $validated['guest_user'], - $validated['guest_password'] - ); - } finally { - $lock->release(); - } - - // Always log the raw PowerShell output for debugging - Log::channel('Custom')->info('[changeIp] raw output', [ - 'server_name' => $validated['server_name'], - 'ip_address' => $validated['ip_address'], - 'gateway' => $gateway, - 'os_type' => $osType, - 'vm_name' => $vm->name, - 'vmware_id' => $vm->vmware_id, - 'raw' => $raw, - ]); - - $telegram = new Telegram(); - - if ($raw === 'Login_Again' || $raw === 'vCenter_Not_Found') { - $errMsg = $raw === 'vCenter_Not_Found' - ? 'Không tìm thấy vCenter cho VM này.' - : 'Không thể đăng nhập vào vCenter.'; - - $telegram->SendErrorMessage("❌ Đổi IP thất bại [{$osType}]\n" - . "🖥 VM: {$vm->name} ({$vm->vmware_id})\n" - . "📍 IP mới: {$validated['ip_address']}\n" - . "🔗 GW: {$gateway}\n" - . "🚫 Lỗi: {$errMsg}"); - - return response()->json(['success' => false, 'message' => $errMsg], 400); - } - - if (str_starts_with((string) $raw, 'Error:') || str_contains((string) $raw, '[STDERR]')) { - $telegram->SendErrorMessage("❌ Đổi IP thất bại [{$osType}]\n" - . "🖥 VM: {$vm->name} ({$vm->vmware_id})\n" - . "📍 IP mới: {$validated['ip_address']}\n" - . "🔗 GW: {$gateway}\n" - . "📋 Output:\n" . mb_substr((string) $raw, 0, 800)); - - return response()->json(['success' => false, 'message' => 'Lệnh đổi IP thất bại. Admin đã được thông báo.', 'data' => ['raw' => $raw]], 400); - } - - // Empty raw = script chạy không có output → coi là thành công (PowerShell script không echo) - // Nhưng vẫn gửi cảnh báo để theo dõi - if (trim((string) $raw) === '') { - $telegram->SendMessage("⚠️ Đổi IP [{$osType}] - Script chạy nhưng không có output\n" - . "🖥 VM: {$vm->name} ({$vm->vmware_id})\n" - . "📍 IP mới: {$validated['ip_address']}\n" - . "🔗 GW: {$gateway}\n" - . "ℹ️ Kiểm tra lại VM để xác nhận IP đã thay đổi chưa."); - } + ChangeIpVMJob::dispatch( + $vm->id, + $validated['ip_address'], + $gateway, + $osType, + $dns, + $validated['guest_user'], + $validated['guest_password'], + ); $this->activityLogger->log( event: 'change_ip', logName: 'VM', - description: "Customer {$request->user()->email} đổi IP VM: {$vm->name} → {$validated['ip_address']}", + description: "Customer {$request->user()->email} yêu cầu đổi IP VM: {$vm->name} → {$validated['ip_address']}", subject: $vm, newValues: ['ip_address' => $validated['ip_address'], 'gateway' => $gateway, 'os_type' => $osType], ); return response()->json([ 'success' => true, - 'message' => 'Đã gửi lệnh đổi IP.', - 'data' => ['raw' => $raw], + 'message' => 'Yêu cầu đổi IP đã được ghi nhận. Hệ thống sẽ xử lý trong vài phút.', ]); } diff --git a/app/Http/Resources/Api/Service/VpsServiceResource.php b/app/Http/Resources/Api/Service/VpsServiceResource.php index ab95447..8fde36f 100644 --- a/app/Http/Resources/Api/Service/VpsServiceResource.php +++ b/app/Http/Resources/Api/Service/VpsServiceResource.php @@ -39,10 +39,8 @@ class VpsServiceResource extends JsonResource $product = $orderItem?->product ?? $this->product; return [ - 'id' => $this->id, 'uuid' => $this->uuid, 'server_name' => $this->server_name, - 'invoice_id' => $this->invoice_id, 'invoice_num' => $invoice?->invoice_number, 'order_item_id' => $this->order_item_id, 'order_num' => $order?->order_num, @@ -56,7 +54,7 @@ class VpsServiceResource extends JsonResource 'group' => $orderItem?->os?->osGroup?->name, ], 'product' => $product ? [ - 'id' => $product->id, + 'uuid' => $product->uuid, 'name' => $product->name, 'slug' => $product->slug, 'product_type' => $product->product_type, diff --git a/app/Http/Services/vCenter/VirtualMachineService.php b/app/Http/Services/vCenter/VirtualMachineService.php index c156f77..83f39e3 100644 --- a/app/Http/Services/vCenter/VirtualMachineService.php +++ b/app/Http/Services/vCenter/VirtualMachineService.php @@ -59,15 +59,16 @@ class VirtualMachineService $userName = customer()?->email; $item = $product_data['item']; + $product = $product_data['product'] ?? null; $host = $product_data['host']; $datacenter = $product_data['datacenter']; $template = $product_data['template']; $addon = $product_data['addon'] ?? ['cpu' => 0, 'ram' => 0, 'disk' => 0]; - $base_cpu = $item->cpu ?? ($host->cpu ?? 1); - $base_ram = $item->ram ?? ($host->ram ?? 1); - $base_disk = $item->disk ?? ($host->disk ?? 20); + $base_cpu = $product->cpu ?? ($host->cpu ?? 1); + $base_ram = $product->ram ?? ($host->ram ?? 1); + $base_disk = $product->disk ?? ($host->disk ?? 20); $base_cpu = max(1, (int) $base_cpu); $base_ram = max(1, (int) $base_ram); diff --git a/app/Jobs/VirtualMachine/ChangeIpVMJob.php b/app/Jobs/VirtualMachine/ChangeIpVMJob.php new file mode 100644 index 0000000..6b113a5 --- /dev/null +++ b/app/Jobs/VirtualMachine/ChangeIpVMJob.php @@ -0,0 +1,120 @@ +find($this->moduleVmwareId); + + if (!$vm) { + Log::error('[ChangeIpVMJob] ModuleVmware not found', ['module_vmware_id' => $this->moduleVmwareId]); + return; + } + + $raw = $action->execute( + $vm, + $this->ipAddress, + $this->gateway, + $this->osType, + $this->dns, + $this->guestUser, + $this->guestPassword, + ); + + Log::channel('Custom')->info('[ChangeIpVMJob] raw output', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'ip_address' => $this->ipAddress, + 'gateway' => $this->gateway, + 'os_type' => $this->osType, + 'vm_name' => $vm->name, + 'vmware_id' => $vm->vmware_id, + 'raw' => $raw, + ]); + + $telegram = new Telegram(); + + if ($raw === 'Login_Again' || $raw === 'vCenter_Not_Found') { + $errMsg = $raw === 'vCenter_Not_Found' + ? 'Không tìm thấy vCenter cho VM này.' + : 'Không thể đăng nhập vào vCenter.'; + + $telegram->SendErrorMessage("❌ Đổi IP thất bại [{$this->osType}]\n" + . "🖥 VM: {$vm->name} ({$vm->vmware_id})\n" + . "📍 IP mới: {$this->ipAddress}\n" + . "🔗 GW: {$this->gateway}\n" + . "🚫 Lỗi: {$errMsg}"); + + return; + } + + if (str_starts_with((string) $raw, 'Error:') || str_contains((string) $raw, '[STDERR]')) { + $telegram->SendErrorMessage("❌ Đổi IP thất bại [{$this->osType}]\n" + . "🖥 VM: {$vm->name} ({$vm->vmware_id})\n" + . "📍 IP mới: {$this->ipAddress}\n" + . "🔗 GW: {$this->gateway}\n" + . "📋 Output:\n" . mb_substr((string) $raw, 0, 800)); + + return; + } + + // Empty raw = script chạy không có output → coi là thành công (PowerShell script không echo) + // Nhưng vẫn gửi cảnh báo để theo dõi + if (trim((string) $raw) === '') { + $telegram->SendMessage("⚠️ Đổi IP [{$this->osType}] - Script chạy nhưng không có output\n" + . "🖥 VM: {$vm->name} ({$vm->vmware_id})\n" + . "📍 IP mới: {$this->ipAddress}\n" + . "🔗 GW: {$this->gateway}\n" + . "ℹ️ Kiểm tra lại VM để xác nhận IP đã thay đổi chưa."); + } + } + + public function failed(\Throwable $e): void + { + Log::error('[ChangeIpVMJob] job failed with exception', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'ip_address' => $this->ipAddress, + 'error' => $e->getMessage(), + ]); + } +} diff --git a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php index 7b4dd55..b96f913 100644 --- a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php +++ b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Jobs\VirtualMachine\Create; use App\Http\Services\vCenter; -use App\Jobs\VirtualMachine\Provision\AssignIpToGuestJob; +use App\Jobs\VirtualMachine\Provision\UpdateVmConfigJob; use App\Models\OrderItem; use App\Models\VmProvisionLock; use App\Services\VirtualMachine\VmAddonResolver; @@ -13,7 +13,6 @@ use App\Services\VirtualMachine\VmInfrastructureResolver; use App\Services\VirtualMachine\VmIpReservationService; use App\Services\VirtualMachine\VmModuleService; use App\Services\VirtualMachine\VmNotificationService; -use App\Services\VirtualMachine\VmPowerService; use App\Services\VirtualMachine\VmProvisionLogger; use App\Services\VirtualMachine\VmServiceSyncService; use App\Services\VirtualMachine\VmVcenterErrorTranslator; @@ -47,7 +46,6 @@ class CreateVirtualMachineJob implements ShouldQueue VmProvisionLogger $vmLogger, VmInfrastructureResolver $infraResolver, VmAddonResolver $addonResolver, - VmPowerService $powerService, VmModuleService $moduleService, VmServiceSyncService $serviceSync, VmNotificationService $notification, @@ -131,6 +129,7 @@ class CreateVirtualMachineJob implements ShouldQueue $result = $vCenter->CreateVirtualMachine([ 'item' => $this->item, + 'product' => $product, 'host' => $infra->host, 'datacenter' => $infra->datacenter, 'vcenter' => $infra->vcenter, @@ -162,21 +161,6 @@ class CreateVirtualMachineJob implements ShouldQueue 'vm_id' => $vmId, ]); - $vmLogger->step('VM start started', ['vm_id' => $vmId]); - - $startResult = $powerService->start($vCenter, $vmId); - - if (is_array($startResult) && ($startResult['error'] ?? false)) { - throw new RuntimeException( - 'Start VM thất bại: ' . json_encode($startResult['data'] ?? null) - ); - } - if ($startResult === 'Login_Again') { - throw new RuntimeException('Start VM thất bại: cần đăng nhập lại vCenter.'); - } - - $vmLogger->success('VM start success', ['vm_id' => $vmId]); - $moduleVmware = $moduleService->create($this->item, $infra, $vmId); $vmLogger->success('ModuleVmware created', [ @@ -191,16 +175,19 @@ class CreateVirtualMachineJob implements ShouldQueue 'order_item_id' => $this->item->id, ]); - AssignIpToGuestJob::dispatch( + // VM is left powered off after clone. UpdateVmConfigJob owns power-on: + // if the order has a CPU/RAM addon it powers off (no-op, already off), + // resizes, then powers on; otherwise it just powers on. Either way it + // dispatches AssignIpToGuestJob next, which needs the VM running. + UpdateVmConfigJob::dispatch( $moduleVmware->id, (string) $vmId, $infra->isWindows ? VmProvisionService::OS_WINDOWS : VmProvisionService::OS_LINUX, - VmProvisionService::TEMPLATE_PASSWORD, - $reservation->id, - $ipPoolId, + reservationId: $reservation->id, + reservedIpPoolId: $ipPoolId, )->onQueue('default'); - $vmLogger->success('AssignIpToGuestJob dispatched', [ + $vmLogger->success('UpdateVmConfigJob dispatched', [ 'module_vmware_id' => $moduleVmware->id, 'vm_id' => $vmId, 'os_family' => $infra->isWindows ? 'WINDOWS' : 'LINUX', diff --git a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php index 36143e4..ddac7f7 100644 --- a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php +++ b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php @@ -31,7 +31,8 @@ use Illuminate\Support\Facades\Log; * * Assigns a real static IP to the VM guest OS and verifies it is active. * Only persists the IP to DB (module_vmware + virtual_machines) after - * successful guest-side verification, then dispatches UpdateVmConfigJob → ChangePasswordViaVsphereJob. + * successful guest-side verification, then dispatches ChangePasswordViaVsphereJob. + * CPU/RAM config (UpdateVmConfigJob) already ran before this job, right after clone. * * Flow: * [1] Resolve network config from order item → product → ipAddress template @@ -41,7 +42,7 @@ use Illuminate\Support\Facades\Log; * [5] On FAIL → release IP, try next * [6] Call verifyIPLinux.ps1 / verifyIPWindows.ps1 via SSH * [7] On FAIL → release IP, try next - * [8] SUCCESS → persistIp() → dispatch UpdateVmConfigJob → ChangePasswordViaVsphereJob + * [8] SUCCESS → persistIp() → dispatch ChangePasswordViaVsphereJob * [9] All attempts exhausted → mark VM inactive (status=0) */ class AssignIpToGuestJob implements ShouldQueue @@ -186,7 +187,7 @@ class AssignIpToGuestJob implements ShouldQueue 'attempt' => $attempt, ]); - UpdateVmConfigJob::dispatch( + ChangePasswordViaVsphereJob::dispatch( $this->moduleVmwareId, $this->vmId, $this->osFamily, @@ -228,8 +229,8 @@ class AssignIpToGuestJob implements ShouldQueue 'attempt' => $attempt, ]); - // Dispatch addon config update → then password change - UpdateVmConfigJob::dispatch( + // CPU/RAM config was already applied by UpdateVmConfigJob before IP assignment. + ChangePasswordViaVsphereJob::dispatch( $this->moduleVmwareId, $this->vmId, $this->osFamily, diff --git a/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php index 490b840..1fcfe00 100644 --- a/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php +++ b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php @@ -12,6 +12,7 @@ use App\Models\Service; use App\Models\ServiceAddon; use App\Models\vCenter; use App\Services\VirtualMachine\VmProvisionLogger; +use App\Services\VmProvisionService; use App\Services\VsphereHostSshService; use Carbon\Carbon; use Illuminate\Bus\Queueable; @@ -26,22 +27,27 @@ use Illuminate\Support\Facades\Log; * UpdateVmConfigJob * * Applies customer-selected CPU/RAM addons (from addons_meta on the OrderItem) - * to the live vSphere VM via updateVMConfig.ps1. + * to the vSphere VM via updateVMConfig.ps1. Runs right after clone, while the + * VM is still powered off — this is also what CreateVirtualMachineJob relies + * on to power the VM on before the next step. * - * Skipped if the order has no cpu/ram addon → goes straight to ChangePasswordViaVsphereJob. + * Skipped if the order has no cpu/ram addon → just power on, dispatch AssignIpToGuestJob. * * PS1 on vSphere host must accept: * $server_name, $cpu, $memoryGB, $vcenter_host, $vcenter_user, $vcenter_password * and call Connect-VIServer before Get-VM. * - * Flow: + * Flow (order-creation path, reservationId set): * [1] Load ModuleVmware → order → orderItems.first() → product * [2] Resolve addon cpu/ram from addons_meta (code field saved by OrderService) - * [3] If no cpu/ram addon → skip, dispatch ChangePasswordViaVsphereJob + * [3] If no cpu/ram addon → power on, dispatch AssignIpToGuestJob * [4] Load vCenter credentials (same pattern as ChangePasswordViaVsphereJob) * [5] Compute total: base (product.cpu/ram) + addon qty - * [6] SSH → vSphere → updateVMConfig.ps1 with vCenter creds - * [7] Assert SUCCESS → dispatch ChangePasswordViaVsphereJob + * [6] Power off → SSH → vSphere → updateVMConfig.ps1 → power on + * [7] Assert SUCCESS → dispatch AssignIpToGuestJob + * + * Manual-override path (admin panel, reservationId null): same power-off → + * resize → power-on, but only updates DB — no further job is dispatched. */ class UpdateVmConfigJob implements ShouldQueue { @@ -57,6 +63,8 @@ class UpdateVmConfigJob implements ShouldQueue public readonly string $osFamily, public readonly ?int $cpuOverride = null, public readonly ?int $ramOverride = null, + public readonly ?int $reservationId = null, + public readonly ?int $reservedIpPoolId = null, ) {} public function handle(VsphereHostSshService $ssh, VmProvisionLogger $logger, Telegram $telegram): void @@ -91,10 +99,16 @@ class UpdateVmConfigJob implements ShouldQueue [$addonCpu, $addonRamGb] = $this->resolveAddonCpuRam($addonsMeta); if ($addonCpu === 0 && $addonRamGb === 0) { - $logger->step('no cpu/ram addon — skipping UpdateVmConfigJob', [ + $logger->step('no cpu/ram addon — powering on and skipping resize', [ 'module_vmware_id' => $this->moduleVmwareId, 'vm_id' => $this->vmId, ]); + + $vcenterForPowerOn = $moduleVmware->vcenter ?? vCenter::first(); + if ($vcenterForPowerOn) { + $this->powerCycle($vcenterForPowerOn, (string) $moduleVmware->vmware_id, $logger, powerOff: false); + } + $this->nextStep(); return; } @@ -133,9 +147,10 @@ class UpdateVmConfigJob implements ShouldQueue 'total_ram_gb' => $totalRam, ]); - if ($isManualOverride) { - $this->powerCycle($vcenter, (string) $moduleVmware->vmware_id, $logger, powerOff: true); - } + // Power off before resizing (both the create-flow addon path and admin + // manual overrides) — hot CPU/RAM resize isn't reliable across guest + // OSes and is what caused Set-VM to reject the config as invalid. + $this->powerCycle($vcenter, (string) $moduleVmware->vmware_id, $logger, powerOff: true); try { $output = $ssh->updateVmConfig( @@ -147,9 +162,7 @@ class UpdateVmConfigJob implements ShouldQueue $vcenterPass, ); } finally { - if ($isManualOverride) { - $this->powerCycle($vcenter, (string) $moduleVmware->vmware_id, $logger, powerOff: false); - } + $this->powerCycle($vcenter, (string) $moduleVmware->vmware_id, $logger, powerOff: false); } Log::info('UpdateVmConfigJob: PS1 output', [ @@ -273,8 +286,9 @@ class UpdateVmConfigJob implements ShouldQueue /** * Power off/on the VM via vCenter REST and wait (up to 30s) for the state to land. - * Used only for manual admin overrides — hot-add isn't reliable for RAM on every guest OS, - * so we power-cycle around the resize to guarantee it applies. + * Hot-add isn't reliable for CPU/RAM resize on every guest OS, so we + * power-cycle around the resize (and around the power-on-only skip path) + * to guarantee the config applies and the VM ends up running either way. */ private function powerCycle(vCenter $vcenter, string $vmwareId, VmProvisionLogger $logger, bool $powerOff): void { @@ -347,10 +361,13 @@ class UpdateVmConfigJob implements ShouldQueue private function nextStep(): void { - ChangePasswordViaVsphereJob::dispatch( + AssignIpToGuestJob::dispatch( $this->moduleVmwareId, $this->vmId, $this->osFamily, + VmProvisionService::TEMPLATE_PASSWORD, + $this->reservationId, + $this->reservedIpPoolId, ); } } diff --git a/app/Repositories/Api/Invoice/InvoiceRepository.php b/app/Repositories/Api/Invoice/InvoiceRepository.php index 0e5feba..48ec32e 100644 --- a/app/Repositories/Api/Invoice/InvoiceRepository.php +++ b/app/Repositories/Api/Invoice/InvoiceRepository.php @@ -47,7 +47,7 @@ class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryIn 'tax' => 0, 'total' => $order->total, 'status' => InvoiceStatus::UNPAID->value, - 'due_date' => now()->addDays(7)->toDateString(), + 'due_date' => now()->addMonths($maxMonths)->toDateString(), ]); // Create one invoice_item per order_item line + addon sub-lines diff --git a/resources/views/pdf/web-design-quote.blade.php b/resources/views/pdf/web-design-quote.blade.php index 52e3c1a..3ab5436 100644 --- a/resources/views/pdf/web-design-quote.blade.php +++ b/resources/views/pdf/web-design-quote.blade.php @@ -151,6 +151,9 @@ body { .items-table td.summary-value { text-align: right; font-weight: bold; } .items-table tr.total td { font-size: 12px; font-weight: bold; } .items-table .item-desc { color: #6b7280; font-size: 9px; } +.items-table .item-desc p { margin: 2px 0; } +.items-table .item-desc ul, +.items-table .item-desc ol { margin: 2px 0; padding-left: 16px; } .items-table { position: relative; z-index: 1; } /* Summary */ @@ -316,7 +319,7 @@ ul.ref-list { margin: 2px 0 0 14px; } {{ $item['name'] ?? '' }} @if (!empty($item['description'])) -
{{ $item['description'] }}
+
{!! $item['description'] !!}
@endif {{ $item['quantity'] ?? 1 }} @@ -359,13 +362,8 @@ ul.ref-list { margin: 2px 0 0 14px; } -
-
Đại diện khách hàng
-
(Ký, ghi rõ họ tên)
-
Đại diện {{ $companyName }}
-
(Ký, ghi rõ họ tên)
@if ($quote->creator)
{{ $quote->creator->name }}
@endif diff --git a/storage/fonts/Times-Roman.afm.json b/storage/fonts/Times-Roman.afm.json new file mode 100644 index 0000000..c96b2fc --- /dev/null +++ b/storage/fonts/Times-Roman.afm.json @@ -0,0 +1,569 @@ +{ + "codeToName": { + "32": "space", + "160": "space", + "33": "exclam", + "34": "quotedbl", + "35": "numbersign", + "36": "dollar", + "37": "percent", + "38": "ampersand", + "146": "quoteright", + "40": "parenleft", + "41": "parenright", + "42": "asterisk", + "43": "plus", + "44": "comma", + "45": "hyphen", + "173": "hyphen", + "46": "period", + "47": "slash", + "48": "zero", + "49": "one", + "50": "two", + "51": "three", + "52": "four", + "53": "five", + "54": "six", + "55": "seven", + "56": "eight", + "57": "nine", + "58": "colon", + "59": "semicolon", + "60": "less", + "61": "equal", + "62": "greater", + "63": "question", + "64": "at", + "65": "A", + "66": "B", + "67": "C", + "68": "D", + "69": "E", + "70": "F", + "71": "G", + "72": "H", + "73": "I", + "74": "J", + "75": "K", + "76": "L", + "77": "M", + "78": "N", + "79": "O", + "80": "P", + "81": "Q", + "82": "R", + "83": "S", + "84": "T", + "85": "U", + "86": "V", + "87": "W", + "88": "X", + "89": "Y", + "90": "Z", + "91": "bracketleft", + "92": "backslash", + "93": "bracketright", + "94": "asciicircum", + "95": "underscore", + "145": "quoteleft", + "97": "a", + "98": "b", + "99": "c", + "100": "d", + "101": "e", + "102": "f", + "103": "g", + "104": "h", + "105": "i", + "106": "j", + "107": "k", + "108": "l", + "109": "m", + "110": "n", + "111": "o", + "112": "p", + "113": "q", + "114": "r", + "115": "s", + "116": "t", + "117": "u", + "118": "v", + "119": "w", + "120": "x", + "121": "y", + "122": "z", + "123": "braceleft", + "124": "bar", + "125": "braceright", + "126": "asciitilde", + "161": "exclamdown", + "162": "cent", + "163": "sterling", + "165": "yen", + "131": "florin", + "167": "section", + "164": "currency", + "39": "quotesingle", + "147": "quotedblleft", + "171": "guillemotleft", + "139": "guilsinglleft", + "155": "guilsinglright", + "150": "endash", + "134": "dagger", + "135": "daggerdbl", + "183": "periodcentered", + "182": "paragraph", + "149": "bullet", + "130": "quotesinglbase", + "132": "quotedblbase", + "148": "quotedblright", + "187": "guillemotright", + "133": "ellipsis", + "137": "perthousand", + "191": "questiondown", + "96": "grave", + "180": "acute", + "136": "circumflex", + "152": "tilde", + "175": "macron", + "168": "dieresis", + "184": "cedilla", + "151": "emdash", + "198": "AE", + "170": "ordfeminine", + "216": "Oslash", + "140": "OE", + "186": "ordmasculine", + "230": "ae", + "248": "oslash", + "156": "oe", + "223": "germandbls", + "207": "Idieresis", + "233": "eacute", + "159": "Ydieresis", + "247": "divide", + "221": "Yacute", + "194": "Acircumflex", + "225": "aacute", + "219": "Ucircumflex", + "253": "yacute", + "234": "ecircumflex", + "220": "Udieresis", + "218": "Uacute", + "203": "Edieresis", + "169": "copyright", + "229": "aring", + "224": "agrave", + "227": "atilde", + "154": "scaron", + "237": "iacute", + "251": "ucircumflex", + "226": "acircumflex", + "231": "ccedilla", + "222": "Thorn", + "179": "threesuperior", + "210": "Ograve", + "192": "Agrave", + "215": "multiply", + "250": "uacute", + "255": "ydieresis", + "238": "icircumflex", + "202": "Ecircumflex", + "228": "adieresis", + "235": "edieresis", + "205": "Iacute", + "177": "plusminus", + "166": "brokenbar", + "174": "registered", + "200": "Egrave", + "142": "Zcaron", + "208": "Eth", + "199": "Ccedilla", + "193": "Aacute", + "196": "Adieresis", + "232": "egrave", + "211": "Oacute", + "243": "oacute", + "239": "idieresis", + "212": "Ocircumflex", + "217": "Ugrave", + "254": "thorn", + "178": "twosuperior", + "214": "Odieresis", + "181": "mu", + "236": "igrave", + "190": "threequarters", + "153": "trademark", + "204": "Igrave", + "189": "onehalf", + "244": "ocircumflex", + "241": "ntilde", + "201": "Eacute", + "188": "onequarter", + "138": "Scaron", + "176": "degree", + "242": "ograve", + "249": "ugrave", + "209": "Ntilde", + "245": "otilde", + "195": "Atilde", + "197": "Aring", + "213": "Otilde", + "206": "Icircumflex", + "172": "logicalnot", + "246": "odieresis", + "252": "udieresis", + "240": "eth", + "158": "zcaron", + "185": "onesuperior", + "128": "Euro" + }, + "isUnicode": false, + "FontName": "Times-Roman", + "FullName": "Times Roman", + "FamilyName": "Times", + "Weight": "Roman", + "ItalicAngle": "0", + "IsFixedPitch": "false", + "CharacterSet": "ExtendedRoman", + "FontBBox": [ + "-168", + "-218", + "1000", + "898" + ], + "UnderlinePosition": "-100", + "UnderlineThickness": "50", + "Version": "002.00", + "EncodingScheme": "WinAnsiEncoding", + "CapHeight": "662", + "XHeight": "450", + "Ascender": "683", + "Descender": "-217", + "StdHW": "28", + "StdVW": "84", + "StartCharMetrics": "317", + "C": { + "32": 250, + "160": 250, + "33": 333, + "34": 408, + "35": 500, + "36": 500, + "37": 833, + "38": 778, + "146": 333, + "40": 333, + "41": 333, + "42": 500, + "43": 564, + "44": 250, + "45": 333, + "173": 333, + "46": 250, + "47": 278, + "48": 500, + "49": 500, + "50": 500, + "51": 500, + "52": 500, + "53": 500, + "54": 500, + "55": 500, + "56": 500, + "57": 500, + "58": 278, + "59": 278, + "60": 564, + "61": 564, + "62": 564, + "63": 444, + "64": 921, + "65": 722, + "66": 667, + "67": 667, + "68": 722, + "69": 611, + "70": 556, + "71": 722, + "72": 722, + "73": 333, + "74": 389, + "75": 722, + "76": 611, + "77": 889, + "78": 722, + "79": 722, + "80": 556, + "81": 722, + "82": 667, + "83": 556, + "84": 611, + "85": 722, + "86": 722, + "87": 944, + "88": 722, + "89": 722, + "90": 611, + "91": 333, + "92": 278, + "93": 333, + "94": 469, + "95": 500, + "145": 333, + "97": 444, + "98": 500, + "99": 444, + "100": 500, + "101": 444, + "102": 333, + "103": 500, + "104": 500, + "105": 278, + "106": 278, + "107": 500, + "108": 278, + "109": 778, + "110": 500, + "111": 500, + "112": 500, + "113": 500, + "114": 333, + "115": 389, + "116": 278, + "117": 500, + "118": 500, + "119": 722, + "120": 500, + "121": 500, + "122": 444, + "123": 480, + "124": 200, + "125": 480, + "126": 541, + "161": 333, + "162": 500, + "163": 500, + "fraction": 167, + "165": 500, + "131": 500, + "167": 500, + "164": 500, + "39": 180, + "147": 444, + "171": 500, + "139": 333, + "155": 333, + "fi": 556, + "fl": 556, + "150": 500, + "134": 500, + "135": 500, + "183": 250, + "182": 453, + "149": 350, + "130": 333, + "132": 444, + "148": 444, + "187": 500, + "133": 1000, + "137": 1000, + "191": 444, + "96": 333, + "180": 333, + "136": 333, + "152": 333, + "175": 333, + "breve": 333, + "dotaccent": 333, + "168": 333, + "ring": 333, + "184": 333, + "hungarumlaut": 333, + "ogonek": 333, + "caron": 333, + "151": 1000, + "198": 889, + "170": 276, + "Lslash": 611, + "216": 722, + "140": 889, + "186": 310, + "230": 667, + "dotlessi": 278, + "lslash": 278, + "248": 500, + "156": 722, + "223": 500, + "207": 333, + "233": 444, + "abreve": 444, + "uhungarumlaut": 500, + "ecaron": 444, + "159": 722, + "247": 564, + "221": 722, + "194": 722, + "225": 444, + "219": 722, + "253": 500, + "scommaaccent": 389, + "234": 444, + "Uring": 722, + "220": 722, + "aogonek": 444, + "218": 722, + "uogonek": 500, + "203": 611, + "Dcroat": 722, + "commaaccent": 250, + "169": 760, + "Emacron": 611, + "ccaron": 444, + "229": 444, + "Ncommaaccent": 722, + "lacute": 278, + "224": 444, + "Tcommaaccent": 611, + "Cacute": 667, + "227": 444, + "Edotaccent": 611, + "154": 389, + "scedilla": 389, + "237": 278, + "lozenge": 471, + "Rcaron": 667, + "Gcommaaccent": 722, + "251": 500, + "226": 444, + "Amacron": 722, + "rcaron": 333, + "231": 444, + "Zdotaccent": 611, + "222": 556, + "Omacron": 722, + "Racute": 667, + "Sacute": 556, + "dcaron": 588, + "Umacron": 722, + "uring": 500, + "179": 300, + "210": 722, + "192": 722, + "Abreve": 722, + "215": 564, + "250": 500, + "Tcaron": 611, + "partialdiff": 476, + "255": 500, + "Nacute": 722, + "238": 278, + "202": 611, + "228": 444, + "235": 444, + "cacute": 444, + "nacute": 500, + "umacron": 500, + "Ncaron": 722, + "205": 333, + "177": 564, + "166": 200, + "174": 760, + "Gbreve": 722, + "Idotaccent": 333, + "summation": 600, + "200": 611, + "racute": 333, + "omacron": 500, + "Zacute": 611, + "142": 611, + "greaterequal": 549, + "208": 722, + "199": 667, + "lcommaaccent": 278, + "tcaron": 326, + "eogonek": 444, + "Uogonek": 722, + "193": 722, + "196": 722, + "232": 444, + "zacute": 444, + "iogonek": 278, + "211": 722, + "243": 500, + "amacron": 444, + "sacute": 389, + "239": 278, + "212": 722, + "217": 722, + "Delta": 612, + "254": 500, + "178": 300, + "214": 722, + "181": 500, + "236": 278, + "ohungarumlaut": 500, + "Eogonek": 611, + "dcroat": 500, + "190": 750, + "Scedilla": 556, + "lcaron": 344, + "Kcommaaccent": 722, + "Lacute": 611, + "153": 980, + "edotaccent": 444, + "204": 333, + "Imacron": 333, + "Lcaron": 611, + "189": 750, + "lessequal": 549, + "244": 500, + "241": 500, + "Uhungarumlaut": 722, + "201": 611, + "emacron": 444, + "gbreve": 500, + "188": 750, + "138": 556, + "Scommaaccent": 556, + "Ohungarumlaut": 722, + "176": 400, + "242": 500, + "Ccaron": 667, + "249": 500, + "radical": 453, + "Dcaron": 722, + "rcommaaccent": 333, + "209": 722, + "245": 500, + "Rcommaaccent": 667, + "Lcommaaccent": 611, + "195": 722, + "Aogonek": 722, + "197": 722, + "213": 722, + "zdotaccent": 444, + "Ecaron": 611, + "Iogonek": 333, + "kcommaaccent": 500, + "minus": 564, + "206": 333, + "ncaron": 500, + "tcommaaccent": 278, + "172": 564, + "246": 500, + "252": 500, + "notequal": 549, + "gcommaaccent": 500, + "240": 500, + "158": 444, + "ncommaaccent": 500, + "185": 300, + "imacron": 278, + "128": 500 + }, + "CIDtoGID_Compressed": true, + "CIDtoGID": "eJwDAAAAAAE=", + "_version_": 6 +} \ No newline at end of file