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:
co-authored by
Claude Opus 5.5
parent
ea5697cb81
commit
46aa138eec
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user