- 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>
279 lines
11 KiB
PHP
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);
|
|
}
|
|
}
|