Files
portal-api/app/Services/IpPool/IpAllocationService.php
T
baodev0andClaude Opus 5.5 46aa138eec 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>
2026-10-15 19:58:36 +07:00

279 lines
11 KiB
PHP

<?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);
}
}