$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 $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 $ids * @return array> */ 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 $ids * @param array $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 $ids * @return Collection */ 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> */ 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); } }