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.
This commit is contained in:
baodev0
2026-07-24 09:29:43 +07:00
parent ccf95114f6
commit 3ed33ea7e4
10 changed files with 767 additions and 127 deletions
@@ -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.',
]);
}
@@ -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,
@@ -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);
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Jobs\VirtualMachine;
use App\Actions\VM\ChangeIpVMAction;
use App\Http\Services\Telegram;
use App\Models\ModuleVmware;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* ChangeIpVMJob
*
* Customer-initiated VM IP change. Runs the same ChangeIpVMAction that used
* to execute synchronously inside VmController::changeIp — moved to a queued
* job so the SSH → PowerCLI round trip can no longer blow past nginx/php-fpm's
* timeout and surface as a 502.
*/
class ChangeIpVMJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** No auto-retry — mirrors the previous synchronous behaviour (single attempt). */
public int $tries = 1;
public int $timeout = 180;
public function __construct(
public readonly int $moduleVmwareId,
public readonly string $ipAddress,
public readonly string $gateway,
public readonly string $osType,
public readonly string $dns,
public readonly string $guestUser,
public readonly string $guestPassword,
) {}
public function handle(ChangeIpVMAction $action): void
{
$vm = ModuleVmware::with([
'vCenter',
'ipAddress.host.datacenter.vCenter',
])->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(),
]);
}
}
@@ -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',
@@ -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,
@@ -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,
);
}
}
@@ -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
@@ -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; }
<td>
{{ $item['name'] ?? '' }}
@if (!empty($item['description']))
<div class="item-desc">{{ $item['description'] }}</div>
<div class="item-desc">{!! $item['description'] !!}</div>
@endif
</td>
<td class="center">{{ $item['quantity'] ?? 1 }}</td>
@@ -359,13 +362,8 @@ ul.ref-list { margin: 2px 0 0 14px; }
<table class="sign-table">
<tr>
<td>
<div class="sign-title">Đại diện khách hàng</div>
<div class="sign-hint">(Ký, ghi rõ họ tên)</div>
</td>
<td>
<div class="sign-title">Đại diện {{ $companyName }}</div>
<div class="sign-hint">(Ký, ghi rõ họ tên)</div>
@if ($quote->creator)
<div style="margin-top: 16mm;">{{ $quote->creator->name }}</div>
@endif
+569
View File
@@ -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
}