feat(admin): IP pool allocation, billing exports, service attachments

- IP pool: sheet import (xlsx/csv) matched by address, bulk allocate/revoke
  with notification email (ip_allocated/ip_revoked), ip_pool_logs history.
- Orders/invoices: Excel export on branded template (BrandedReportWriter),
  shared billing filters, order item service info in list responses.
- Web design quote Excel export; activity log export.
- Service attachments with copy-to-invoice; deployment invoice attachments.
- Customer email attachments stored on disk and attached by path.
- Admin password change endpoint; user.fullname backfill migration;
  orders.user_id backfill from logs.
- Disable deploy workflow until CI/CD is set up.
- Ignore stored customer email and service attachment uploads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-15 19:58:36 +07:00
co-authored by Claude Opus 5.5
parent ea5697cb81
commit 46aa138eec
95 changed files with 4247 additions and 454 deletions
@@ -3,6 +3,7 @@
namespace App\Services\Customer;
use App\Models\Invoice;
use App\Http\Resources\Api\Admin\OrderItemServiceResource;
use App\Models\Order;
use App\Enums\TransactionStatus;
use App\Models\Transaction;
@@ -127,7 +128,13 @@ class CustomerBillingService
{
$customer = $this->resolveCustomer($uuid);
return $this->orderRepository->filter(['client' => $customer->id], ['customer', 'payment', 'invoices']);
return $this->orderRepository->filter(['client' => $customer->id], [
'customer',
'user:id,username',
'payment',
'invoices',
...OrderItemServiceResource::relationsFor('orderItems'),
]);
}
/**
@@ -16,6 +16,7 @@ use App\Services\Customer\Concerns\ResolvesCustomer;
use App\Services\Email\EmailTemplateRenderer;
use App\Support\AutoEmailGate;
use App\Support\EmailRecipients;
use App\Support\MailAttachments;
use App\Support\MailGlobals;
use App\Support\MailPlaceholders;
use Illuminate\Support\Facades\DB;
@@ -165,6 +166,8 @@ class CustomerMailService
abort_if($to === [], 422, 'Không có địa chỉ email nhận hợp lệ.');
$files = MailAttachments::store($data['attachments'] ?? [], "customer-emails/{$customer->id}");
$mailer = Mail::to($to);
if ($cc !== []) {
$mailer->cc($cc);
@@ -173,8 +176,8 @@ class CustomerMailService
$mailer->bcc($bcc);
}
$mailer->send($template
? new GenericTemplateMail($subject, $message, array_diff_key($template->mailConfig(), array_flip(['cc', 'bcc'])))
: new ManualCustomerEmail($subject, $message, wrapLayout: (bool) ($data['with_layout'] ?? true)));
? new GenericTemplateMail($subject, $message, array_diff_key($template->mailConfig(), array_flip(['cc', 'bcc'])), storedFiles: $files)
: new ManualCustomerEmail($subject, $message, wrapLayout: (bool) ($data['with_layout'] ?? true), storedFiles: $files));
MailLog::create([
'template_id' => $template->id ?? 0,
+278
View File
@@ -0,0 +1,278 @@
<?php
namespace App\Services\IpPool;
use App\Mail\IpAllocationMail;
use App\Models\Customer;
use App\Models\IpPool;
use App\Models\IpPoolLog;
use App\Models\Mail as MailLog;
use App\Models\ModuleIpAddress;
use App\Models\User;
use App\Services\Email\EmailTemplateService;
use App\Support\AutoEmailGate;
use App\Support\EmailRecipients;
use App\Support\MailAttachments;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
class IpAllocationService
{
public function __construct(private readonly EmailTemplateService $templates) {}
/**
* @param array<int, int> $ids
*/
public function previewAllocation(array $ids, Customer $customer, array $data): array
{
$pools = $this->allocatable($this->pools($ids));
$pools->each(fn (IpPool $pool) => $pool->service_name = $this->serviceNameFor($pool, $data));
return $this->preview($this->allocationMail($customer, $pools, $data));
}
/**
* @param array<int, int> $ids
*/
public function allocate(array $ids, Customer $customer, array $data, User $admin): int
{
$this->ensureMailReady(IpAllocationMail::ALLOCATED);
$files = MailAttachments::store($data['attachments'] ?? [], "customer-emails/{$customer->id}");
return DB::transaction(function () use ($ids, $customer, $data, $admin, $files) {
$pools = $this->allocatable($this->pools($ids, lock: true));
foreach ($pools as $pool) {
$from = $pool->status;
$pool->update([
'status' => 'assigned',
'customer_id' => $customer->id,
'customer_name' => $customer->fullname,
'service_name' => $this->serviceNameFor($pool, $data),
'allocated_at' => $data['date'],
'revoked_at' => null,
'contact_email' => $data['to'],
'contact_cc' => $data['cc'] ?? null,
'last_email_type' => 'ALLOCATED_SENT',
'last_email_at' => now(),
'updated_by' => $admin->username,
]);
IpPoolLog::record($pool, IpPoolLog::ALLOCATED, $from, [
'effective_date' => $data['date'],
'customer_id' => $customer->id,
'customer_name' => $customer->fullname,
'service_name' => $pool->service_name,
'email_to' => $data['to'],
'note' => $data['note'] ?? null,
]);
}
$this->send($this->allocationMail($customer, $pools, $data, $files), $data['to'], $data['cc'] ?? null, $customer->id, $admin);
return $pools->count();
});
}
/**
* @param array<int, int> $ids
* @return array<int, array<string, mixed>>
*/
public function previewRevocation(array $ids, array $data): array
{
return $this->revocationGroups($this->revocable($this->pools($ids)))
->map(fn (Collection $pools, string $key) => [
'key' => $key,
'customer_id' => $pools->first()->customer_id,
'customer_name' => $pools->first()->customer_name,
'to' => $pools->first()->contact_email,
'cc' => $pools->first()->contact_cc,
'ips' => $pools->pluck('ip_address')->values(),
...$this->preview($this->revocationMail($pools, $data)),
])
->values()
->all();
}
/**
* @param array<int, int> $ids
* @param array<string, array{to: string, cc?: string|null}> $recipients
*/
public function revoke(array $ids, array $data, array $recipients, User $admin): int
{
$this->ensureMailReady(IpAllocationMail::REVOKED);
$groups = $this->revocationGroups($this->revocable($this->pools($ids)));
$missing = $groups->keys()->reject(fn ($key) => filled($recipients[$key]['to'] ?? null));
if ($missing->isNotEmpty()) {
throw ValidationException::withMessages(['recipients' => 'Chưa nhập email người nhận cho: ' . $groups->only($missing->all())
->map(fn ($pools) => $pools->first()->customer_name ?: $pools->pluck('ip_address')->implode(', '))->implode('; ')]);
}
return DB::transaction(function () use ($ids, $data, $recipients, $admin) {
$groups = $this->revocationGroups($this->revocable($this->pools($ids, lock: true)));
return $groups->map(function (Collection $pools, string $key) use ($data, $recipients, $admin) {
$mail = $this->revocationMail($pools, $data, $recipients[$key]['subject'] ?? null);
$customerId = $pools->first()->customer_id;
foreach ($pools as $pool) {
$from = $pool->status;
$before = $pool->only(['customer_id', 'customer_name', 'service_name']);
$pool->update([
'status' => 'free',
'customer_id' => null,
'customer_name' => null,
'service_name' => null,
'contact_email' => null,
'revoked_at' => $data['date'],
'last_email_type' => 'REVOKE_SENT',
'last_email_at' => now(),
'updated_by' => $admin->username,
]);
IpPoolLog::record($pool, IpPoolLog::REVOKED, $from, [
...$before,
'effective_date' => $data['date'],
'email_to' => $recipients[$key]['to'],
'note' => $data['note'] ?? null,
]);
}
$this->send($mail, $recipients[$key]['to'], $recipients[$key]['cc'] ?? null, $customerId, $admin);
return $pools->count();
})->sum();
});
}
/**
* @param array<int, int> $ids
* @return Collection<int, IpPool>
*/
private function pools(array $ids, bool $lock = false): Collection
{
$query = IpPool::whereIn('id', $ids)->orderBy('ip_address');
$pools = ($lock ? $query->lockForUpdate() : $query)->get();
if ($pools->isEmpty()) {
throw ValidationException::withMessages(['ids' => 'Không tìm thấy IP nào.']);
}
return $pools;
}
private function allocatable(Collection $pools): Collection
{
$blocked = $pools->reject(fn (IpPool $pool) => $pool->status === 'free');
if ($blocked->isNotEmpty()) {
throw ValidationException::withMessages(['ids' => 'Chỉ cấp phát được IP đang trống. IP không hợp lệ: ' . $blocked->pluck('ip_address')->implode(', ')]);
}
return $pools;
}
private function revocable(Collection $pools): Collection
{
$inUse = ModuleIpAddress::where('status', 1)->whereIn('ip', $pools->pluck('ip_address'))->pluck('ip')->all();
$blocked = $pools->filter(fn (IpPool $pool) => $pool->status !== 'assigned'
|| $pool->service_id !== null
|| in_array($pool->ip_address, $inUse, true));
if ($blocked->isNotEmpty()) {
throw ValidationException::withMessages(['ids' => 'Chỉ thu hồi được IP đã cấp và không gắn VM/dịch vụ (IP gắn VM thu hồi qua dịch vụ). IP không hợp lệ: ' . $blocked->pluck('ip_address')->implode(', ')]);
}
return $pools;
}
/**
* @return \Illuminate\Support\Collection<string, Collection<int, IpPool>>
*/
private function revocationGroups(Collection $pools): \Illuminate\Support\Collection
{
return $pools->groupBy(fn (IpPool $pool) => $pool->customer_id
? 'customer:' . $pool->customer_id
: 'name:' . mb_strtolower(trim((string) $pool->customer_name)) . '|' . mb_strtolower(trim((string) $pool->contact_email)))
->toBase();
}
private function allocationMail(Customer $customer, Collection $pools, array $data, array $files = []): IpAllocationMail
{
return new IpAllocationMail(
IpAllocationMail::ALLOCATED,
$customer->fullname,
IpAllocationMail::ipsFrom($pools),
$data['date'],
$data['note'] ?? null,
$data['subject'] ?? null,
$files,
);
}
private function serviceNameFor(IpPool $pool, array $data): ?string
{
$name = trim((string) (($data['services'][$pool->id] ?? null) ?: ($data['service_name'] ?? '')));
return $name === '' ? null : $name;
}
private function revocationMail(Collection $pools, array $data, ?string $subject = null): IpAllocationMail
{
return new IpAllocationMail(
IpAllocationMail::REVOKED,
$pools->first()->customer_name ?: 'Quý khách',
IpAllocationMail::ipsFrom($pools),
$data['date'],
$data['note'] ?? null,
$subject,
);
}
/**
* @return array{subject: string, html: string}
*/
private function preview(IpAllocationMail $mail): array
{
try {
return ['subject' => $mail->envelope()->subject, 'html' => $mail->render()];
} catch (ModelNotFoundException) {
throw ValidationException::withMessages(['template' => 'Chưa có mẫu email đang bật cho thao tác này (Mẫu email → ' . $mail->type . ').']);
}
}
private function ensureMailReady(string $scenario): void
{
if (!AutoEmailGate::mailEnabled()) {
throw ValidationException::withMessages(['mail' => 'Hệ thống đang tắt gửi email (Mẫu email → Cài đặt → Bật gửi email).']);
}
try {
$this->templates->loadTemplate($scenario);
} catch (ModelNotFoundException) {
throw ValidationException::withMessages(['template' => "Chưa có mẫu email {$scenario} đang bật trong Mẫu email."]);
}
}
private function send(IpAllocationMail $mail, string $to, ?string $cc, ?int $customerId, User $admin): void
{
AutoEmailGate::markManual();
$template = $this->templates->loadTemplate($mail->type);
$toList = EmailRecipients::parse($to);
$ccList = EmailRecipients::parse($cc);
MailLog::create([
'template_id' => $template->id,
'subject' => $mail->envelope()->subject,
'from_address' => $template->mailConfig()['from_email'] ?? config('mail.from.address'),
'to' => implode(', ', $toList),
'cc' => $ccList ? implode(', ', $ccList) : null,
'customer_id' => $customerId,
'sent_by' => $admin->id,
'message' => (clone $mail)->render(),
]);
Mail::to($toList)->cc($ccList)->queue($mail);
}
}
+185
View File
@@ -0,0 +1,185 @@
<?php
namespace App\Services\IpPool;
use App\Models\IpPool;
use App\Models\ModuleIpAddress;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class IpPoolSheetImporter
{
private const ERROR_LIMIT = 100;
private const CHUNK = 500;
private const NETWORK_FIELDS = ['gateway', 'netmask'];
private const COPIED_FIELDS = [
'ip_block', 'gateway', 'netmask', 'owner_type', 'ip_role', 'parent_ip',
'customer_name', 'service_name', 'allocated_at', 'revoked_at',
'contact_email', 'contact_cc', 'contact_phone', 'monthly_cost', 'selling_price',
'additional_info', 'last_email_type', 'last_email_at', 'updated_by', 'note',
];
public function __construct(private readonly IpPoolSheetReader $reader) {}
public function preview(UploadedFile $file): array
{
return $this->run($file, false);
}
public function import(UploadedFile $file): array
{
return $this->run($file, true);
}
public static function resolveStatus(?string $currentStatus, bool $systemOwned, ?string $sheetStatus): string
{
if ($currentStatus !== null && $systemOwned) {
return $currentStatus;
}
return match ($sheetStatus) {
'ALLOCATED' => 'assigned',
'FREE', 'REVOKED' => 'free',
default => $currentStatus ?? 'free',
};
}
private function run(UploadedFile $file, bool $apply): array
{
$sheet = $this->reader->read($file);
$rows = [];
foreach ($sheet['rows'] as $row) {
$rows[$row['ip_address']] = $row;
}
$summary = [
'total_rows' => count($sheet['rows']) + count($sheet['errors']) + $sheet['skipped_blank'],
'valid' => count($rows),
'skipped_blank' => $sheet['skipped_blank'],
'duplicates' => count($sheet['rows']) - count($rows),
'error_count' => count($sheet['errors']),
'errors' => array_slice($sheet['errors'], 0, self::ERROR_LIMIT),
'create' => 0,
'update' => 0,
'restore' => 0,
'to_assigned' => 0,
'to_free' => 0,
'kept_by_system' => 0,
];
[$inserts, $updates] = $this->plan($rows, $summary);
if ($apply) {
DB::transaction(function () use ($inserts, $updates) {
foreach (array_chunk($inserts, self::CHUNK) as $chunk) {
IpPool::insert($chunk);
}
foreach (array_chunk($updates, self::CHUNK) as $chunk) {
IpPool::withTrashed()->upsert($chunk, ['id'], [...self::COPIED_FIELDS, 'cidr', 'status', 'deleted_at']);
}
});
IpPool::resyncAllContacts();
}
return $summary;
}
/**
* @param array<string, array<string, mixed>> $rows
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>}
*/
private function plan(array $rows, array &$summary): array
{
$ips = array_keys($rows);
$existing = $this->existingByIp($ips);
$inUse = $this->inUseKeys($ips);
$now = now()->toDateTimeString();
$inserts = [];
$updates = [];
foreach ($rows as $ip => $row) {
$pools = $existing->get($ip);
if ($pools === null) {
$status = self::resolveStatus(null, false, $row['sheet_status']);
$summary['create']++;
$summary['to_assigned'] += $status === 'assigned' ? 1 : 0;
$inserts[] = $this->record($row, null, $status) + ['created_at' => $now, 'updated_at' => $now];
continue;
}
foreach ($pools as $pool) {
$systemOwned = $pool->status === 'reserved'
|| $pool->service_id !== null
|| isset($inUse[$pool->module_network_id . '|' . $ip]);
$status = self::resolveStatus($pool->status, $systemOwned, $row['sheet_status']);
$summary[$pool->deleted_at ? 'restore' : 'update']++;
if ($systemOwned) {
$summary['kept_by_system']++;
} elseif ($pool->status !== $status) {
$summary[$status === 'assigned' ? 'to_assigned' : 'to_free']++;
}
$updates[] = $this->record($row, $pool, $status);
}
}
return [$inserts, $updates];
}
/**
* @param array<int, string> $ips
* @return Collection<string, Collection<int, IpPool>>
*/
private function existingByIp(array $ips): Collection
{
return collect($ips)->chunk(1000)->flatMap(fn ($chunk) => IpPool::withTrashed()
->whereIn('ip_address', $chunk->all())
->get(['id', 'uuid', 'ip_address', 'module_network_id', 'status', 'service_id', 'cidr', 'gateway', 'netmask', 'deleted_at'])
)->groupBy('ip_address');
}
/**
* @param array<int, string> $ips
* @return array<string, true>
*/
private function inUseKeys(array $ips): array
{
$keys = [];
foreach (array_chunk($ips, 1000) as $chunk) {
foreach (ModuleIpAddress::where('status', 1)->whereIn('ip', $chunk)->get(['ip', 'module_network_id']) as $mirror) {
$keys[$mirror->module_network_id . '|' . $mirror->ip] = true;
}
}
return $keys;
}
private function record(array $row, ?IpPool $pool, string $status): array
{
$record = [
'uuid' => $pool?->uuid ?? (string) Str::uuid(),
'ip_address' => $row['ip_address'],
'module_network_id' => $pool?->module_network_id,
'cidr' => $row['cidr'] ?? $pool?->cidr ?? 24,
'status' => $status,
'deleted_at' => null,
];
foreach (self::COPIED_FIELDS as $field) {
$record[$field] = $row[$field];
}
foreach (self::NETWORK_FIELDS as $field) {
$record[$field] ??= $pool?->{$field};
}
return $pool ? ['id' => $pool->id] + $record : $record;
}
}
+258
View File
@@ -0,0 +1,258 @@
<?php
namespace App\Services\IpPool;
use Carbon\Carbon;
use Carbon\Exceptions\InvalidFormatException;
use DateTimeInterface;
use Illuminate\Http\UploadedFile;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class IpPoolSheetReader
{
public const SHEET_NAME = 'IP_POOL';
private const READERS = ['xlsx' => 'Xlsx', 'xls' => 'Xls', 'csv' => 'Csv', 'txt' => 'Csv'];
private const HEADERS = [
'TEN_MANG' => 'ip_block',
'CIDR' => 'cidr',
'OWNER_TYPE' => 'owner_type',
'IP_ADDRESS' => 'ip_address',
'IP_ROLE' => 'ip_role',
'PARENT_IP' => 'parent_ip',
'SUBNET_MASK' => 'netmask',
'GATEWAY' => 'gateway',
'TEN_KHACH_HANG' => 'customer_name',
'TEN_DICH_VU' => 'service_name',
'NGAY_CAP_PHAT' => 'allocated_at',
'NGAY_THU_HOI' => 'revoked_at',
'TRANG_THAI' => 'sheet_status',
'CONTACT_EMAIL' => 'contact_email',
'CONTACT_CC' => 'contact_cc',
'CONTACT_PHONE' => 'contact_phone',
'MONTHLY_COST' => 'monthly_cost',
'SELLING_PRICE' => 'selling_price',
'THONG_TIN_BO_SUNG' => 'additional_info',
'LAST_EMAIL_TYPE' => 'last_email_type',
'LAST_EMAIL_AT' => 'last_email_at',
'UPDATED_BY' => 'updated_by',
'GHI_CHU' => 'note',
];
private const UPPERCASE_FIELDS = ['owner_type', 'ip_role', 'sheet_status', 'last_email_type'];
/**
* @return array{rows: array<int, array<string, mixed>>, errors: array<int, array{row: int, message: string}>, skipped_blank: int}
*/
public function read(UploadedFile $file): array
{
$type = self::READERS[strtolower($file->getClientOriginalExtension())] ?? 'Csv';
$reader = IOFactory::createReader($type);
$reader->setReadDataOnly(true);
if ($type !== 'Csv') {
$sheets = $reader->listWorksheetNames($file->getRealPath());
$reader->setLoadSheetsOnly(in_array(self::SHEET_NAME, $sheets, true) ? self::SHEET_NAME : $sheets[0]);
}
return $this->parse($this->cachedValues($reader->load($file->getRealPath())->getActiveSheet()));
}
/**
* @return array<int, array<int, mixed>>
*/
private function cachedValues(Worksheet $sheet): array
{
$table = [];
foreach ($sheet->getRowIterator() as $row) {
$cells = [];
foreach ($row->getCellIterator() as $cell) {
$cells[] = $cell->isFormula() ? $cell->getOldCalculatedValue() : $cell->getValue();
}
$table[] = $cells;
}
return $table;
}
/**
* @param array<int, array<int, mixed>> $table
*/
public function parse(array $table): array
{
$columns = $this->mapHeader(array_shift($table) ?? []);
$rows = [];
$errors = [];
$skippedBlank = 0;
foreach ($table as $index => $cells) {
$line = $index + 2;
$raw = [];
foreach ($columns as $position => $field) {
$raw[$field] = $cells[$position] ?? null;
}
if ($this->clean($raw['ip_address'] ?? null) === null) {
$skippedBlank++;
continue;
}
$problems = [];
$row = $this->normalize($raw, $problems);
if ($problems) {
$errors[] = ['row' => $line, 'message' => implode('; ', $problems)];
continue;
}
$rows[$line] = $row;
}
return ['rows' => $rows, 'errors' => $errors, 'skipped_blank' => $skippedBlank];
}
/**
* @return array<int, string>
*/
private function mapHeader(array $header): array
{
$columns = [];
foreach ($header as $position => $title) {
$field = self::HEADERS[strtoupper((string) $this->clean($title))] ?? null;
if ($field && !in_array($field, $columns, true)) {
$columns[$position] = $field;
}
}
return $columns;
}
/**
* @param array<int, string> $problems
*/
private function normalize(array $raw, array &$problems): array
{
$row = [];
foreach (array_keys(self::HEADERS) as $header) {
$field = self::HEADERS[$header];
$row[$field] = $this->clean($raw[$field] ?? null);
}
foreach (self::UPPERCASE_FIELDS as $field) {
$row[$field] = $row[$field] === null ? null : mb_strtoupper($row[$field]);
}
$row['ip_block'] = $row['ip_block'] === null ? null : preg_replace('/\s+/', ' ', $row['ip_block']);
if (!filter_var($row['ip_address'], FILTER_VALIDATE_IP)) {
$problems[] = "IP không hợp lệ: {$row['ip_address']}";
}
$row['cidr'] = preg_match('/(\d{1,2})$/', (string) $row['cidr'], $m) && (int) $m[1] <= 32 ? (int) $m[1] : null;
$row['contact_email'] = $this->emails($row['contact_email'], 'CONTACT_EMAIL', $problems);
$row['contact_cc'] = $this->emails($row['contact_cc'], 'CONTACT_CC', $problems);
$row['contact_phone'] = $this->phone($row['contact_phone']);
$row['monthly_cost'] = $this->money($raw['monthly_cost'] ?? null, 'MONTHLY_COST', $problems);
$row['selling_price'] = $this->money($raw['selling_price'] ?? null, 'SELLING_PRICE', $problems);
$row['allocated_at'] = $this->date($raw['allocated_at'] ?? null, false);
$row['revoked_at'] = $this->date($raw['revoked_at'] ?? null, false);
$row['last_email_at'] = $this->date($raw['last_email_at'] ?? null, true);
return $row;
}
private function clean(mixed $value): ?string
{
if ($value === null || $value instanceof DateTimeInterface) {
return $value?->format('Y-m-d H:i:s');
}
$value = trim(str_replace("\u{00A0}", ' ', (string) $value), " \t\n\r\0\x0B");
return $value === '' ? null : $value;
}
private function emails(?string $value, string $label, array &$problems): ?string
{
if ($value === null) {
return null;
}
$emails = array_values(array_filter(array_map('trim', preg_split('/[,;\s]+/', $value))));
foreach ($emails as $email) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$problems[] = "{$label} không hợp lệ: {$email}";
}
}
return $emails ? implode(', ', $emails) : null;
}
private function phone(?string $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/[\s.\-]/', '', $value);
return preg_match('/^[1-9]\d{8}$/', $digits) ? '0' . $digits : $digits;
}
private function money(mixed $value, string $label, array &$problems): ?float
{
$text = $this->clean($value);
if ($text === null) {
return null;
}
if (is_numeric($text)) {
return round((float) $text, 2);
}
if (preg_match('/^\d{1,3}([.,]\d{3})+$/', $text)) {
return (float) preg_replace('/[.,]/', '', $text);
}
$problems[] = "{$label} không phải số: {$text}";
return null;
}
private function date(mixed $value, bool $withTime): ?string
{
if ($value instanceof DateTimeInterface) {
$date = Carbon::instance($value);
} elseif (is_numeric($value)) {
$date = Carbon::instance(ExcelDate::excelToDateTimeObject((float) $value));
} else {
$date = $this->parseDateText($this->clean($value));
}
if ($date === null) {
return null;
}
return $withTime ? $date->toDateTimeString() : $date->toDateString();
}
private function parseDateText(?string $text): ?Carbon
{
if ($text === null) {
return null;
}
foreach (['j/n/Y H:i:s', 'j/n/Y H:i', 'j/n/Y', 'Y-m-d H:i:s', 'Y-m-d'] as $format) {
try {
return Carbon::createFromFormat('!' . $format, $text);
} catch (InvalidFormatException) {
continue;
}
}
return null;
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace App\Services\IpPool;
use App\Models\IpPool;
use App\Models\ModuleIpAddress;
use App\Models\ModuleVmware;
use App\Models\Service;
use App\Models\VirtualMachines;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class IpPoolUsageResolver
{
private const CHUNK = 1000;
/**
* @param Collection<int, IpPool> $pools
* @return array<string, array<string, mixed>>
*/
public function forPools(Collection $pools): array
{
$inUse = $this->chunked($pools->pluck('ip_address'), fn ($ips) => ModuleIpAddress::where('status', 1)
->whereIn('ip', $ips)
->get(['id', 'ip', 'vm_name']))
->keyBy('ip');
if ($inUse->isEmpty()) {
return [];
}
$ips = $inUse->keys();
$vmwares = $this->vmwares($inUse);
$services = $this->services($vmwares);
$vcenterVms = $this->vcenterVms($ips, $inUse->pluck('vm_name')->filter());
return $inUse->mapWithKeys(function (ModuleIpAddress $mirror, string $ip) use ($vmwares, $services, $vcenterVms) {
$vmware = $vmwares->get($ip);
$service = $vmware ? $services->get($vmware->id) : null;
$vcenterVm = $vcenterVms->get($ip);
return [$ip => [
'assigned' => true,
'vm_name' => $vmware?->name ?? $vcenterVm?->name ?? $mirror->vm_name,
'vm_id' => $vmware?->id,
'vcenter_vm_id' => $vcenterVm?->id,
'service_id' => $service?->id,
'service_name' => $service?->server_name,
'service_status' => $service?->status,
'customer_name' => $service?->customer?->fullname,
'customer_email' => $service?->customer?->email,
'customer_uuid' => $service?->customer?->uuid,
]];
})->all();
}
/**
* @param Collection<string, ModuleIpAddress> $inUse
* @return Collection<string, ModuleVmware>
*/
private function vmwares(Collection $inUse): Collection
{
$mirrorIp = $inUse->mapWithKeys(fn (ModuleIpAddress $mirror) => [$mirror->id => $mirror->ip]);
return ModuleVmware::query()
->where(fn ($query) => $query->whereIn('module_ip_address', $mirrorIp->keys())->orWhereIn('ip', $inUse->keys()))
->get(['id', 'name', 'ip', 'module_ip_address', 'service_id', 'customer_id'])
->keyBy(fn (ModuleVmware $vm) => $mirrorIp[$vm->module_ip_address] ?? $vm->ip);
}
/**
* @param Collection<string, ModuleVmware> $vmwares
* @return Collection<int, Service>
*/
private function services(Collection $vmwares): Collection
{
if ($vmwares->isEmpty()) {
return collect();
}
$services = Service::with('customer:id,uuid,fullname,email')
->where(fn ($query) => $query->whereIn('id', $vmwares->pluck('service_id')->filter())->orWhereIn('vm_id', $vmwares->pluck('id')))
->get(['id', 'server_name', 'status', 'customer_id', 'vm_id']);
return $vmwares->mapWithKeys(fn (ModuleVmware $vm) => [
$vm->id => $services->firstWhere('id', $vm->service_id) ?? $services->firstWhere('vm_id', $vm->id),
])->filter();
}
/**
* @param Collection<int, string> $ips
* @param Collection<int, string> $vmNames
* @return Collection<string, VirtualMachines>
*/
private function vcenterVms(Collection $ips, Collection $vmNames): Collection
{
$prefix = DB::raw("SUBSTRING_INDEX(name, '_', 1)");
return VirtualMachines::query()
->where(fn ($query) => $query->whereIn('ip', $ips)->orWhereIn('name', $vmNames)->orWhereIn($prefix, $ips))
->orderByDesc('id')
->get(['id', 'name', 'ip'])
->reduce(function (Collection $byIp, VirtualMachines $vm) use ($ips) {
$ip = $vm->ip && $ips->contains($vm->ip) ? $vm->ip : strstr($vm->name, '_', true);
if ($ip && $ips->contains($ip) && !$byIp->has($ip)) {
$byIp->put($ip, $vm);
}
return $byIp;
}, collect());
}
private function chunked(Collection $values, callable $query): Collection
{
return $values->chunk(self::CHUNK)->flatMap(fn (Collection $chunk) => $query($chunk->values()->all()));
}
}
@@ -12,6 +12,7 @@ use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\Service;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class ServiceOrderRecorder
@@ -35,6 +36,7 @@ class ServiceOrderRecorder
$order = Order::create([
'order_num' => OrderHelper::generateOrderNumber(),
'customer_id' => $customerId,
'user_id' => Auth::guard('admin')->id(),
'date' => now()->toDateString(),
'sub_total' => $price,
'discount_amount' => 0,