Add bulk update gateway endpoint to vCenter API routes

- Introduced a new POST route 'bulk-update-gateway' for bulk updating gateways.
- This route is protected by the 'module-vcenter.manager' permission middleware.
This commit is contained in:
baodev0
2026-08-06 15:32:51 +07:00
parent 90f2c97bb4
commit 79f06c2fa2
9 changed files with 5555 additions and 19 deletions
@@ -15,8 +15,11 @@ use Maatwebsite\Excel\Validators\ValidationException;
class IpPoolController extends Controller
{
/**
* Descriptive/CSV-import fields — plain pass-through, unlike gateway/cidr/netmask/
* status/service_id which have defaulting + module_ip_address mirror logic below.
*/
private const EDITABLE_FIELDS = [
'gateway', 'netmask', 'service_id', 'status',
'owner_type', 'ip_role', 'parent_ip', 'customer_name', 'service_name',
'allocated_at', 'revoked_at', 'allocation_status',
'contact_email', 'contact_cc', 'contact_phone',
@@ -24,9 +27,25 @@ class IpPoolController extends Controller
'last_email_type', 'last_email_at', 'updated_by', 'note',
];
/**
* contact_email/contact_cc may hold multiple addresses separated by comma/semicolon.
*/
private function multiEmailRule(): \Closure
{
return function (string $attribute, mixed $value, \Closure $fail) {
foreach (preg_split('/[,;]+/', (string) $value) as $email) {
$email = trim($email);
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$fail("Email không hợp lệ: {$email}");
}
}
};
}
public function index(Request $request): JsonResponse
{
$perPage = min((int) $request->get('per_page', 15), 500);
// 'all' lets the admin grid load the full dataset once and filter/sort client-side.
$perPage = $request->boolean('all') ? 20000 : min((int) $request->get('per_page', 15), 500);
$filter = $request->get('filter');
$networkId = $request->get('network_id');
@@ -133,6 +152,10 @@ class IpPoolController extends Controller
'netmask' => 'nullable|string|max:45',
'status' => 'nullable|in:free,assigned,reserved',
'service_id' => 'nullable|integer|exists:services,id',
'contact_email' => ['nullable', 'string', $this->multiEmailRule()],
'contact_cc' => ['nullable', 'string', $this->multiEmailRule()],
'monthly_cost' => 'nullable|numeric',
'selling_price' => 'nullable|numeric',
]);
$networkId = (int) $request->module_network_id;
@@ -174,6 +197,7 @@ class IpPoolController extends Controller
$hostId = ModuleNetwork::find($networkId)?->module_host_id;
$netmask = $request->netmask ?: '255.255.255.0';
$cidr = $request->cidr ?? 24;
$extra = $request->only(self::EDITABLE_FIELDS);
DB::beginTransaction();
try {
@@ -187,6 +211,7 @@ class IpPoolController extends Controller
'netmask' => $netmask,
'status' => $request->status ?: 'free',
'service_id' => $request->service_id,
...$extra,
]);
// Mirror into module_ip_address so both tables stay in sync.
@@ -229,6 +254,10 @@ class IpPoolController extends Controller
'netmask' => 'nullable|string|max:45',
'status' => 'nullable|in:free,assigned,reserved',
'service_id' => 'nullable|integer|exists:services,id',
'contact_email' => ['nullable', 'string', $this->multiEmailRule()],
'contact_cc' => ['nullable', 'string', $this->multiEmailRule()],
'monthly_cost' => 'nullable|numeric',
'selling_price' => 'nullable|numeric',
]);
if (!filter_var($request->ip_address, FILTER_VALIDATE_IP)) {
@@ -256,6 +285,7 @@ class IpPoolController extends Controller
try {
$pool->update([
...$request->only(['ip_address', 'module_network_id', 'gateway', 'cidr', 'service_id']),
...$request->only(self::EDITABLE_FIELDS),
'netmask' => $netmask,
'status' => $request->status ?: $pool->status,
]);
@@ -312,6 +342,44 @@ class IpPoolController extends Controller
return response()->json(['error' => false, 'message' => 'Đã xoá IP Pool thành công']);
}
public function bulkUpdateGateway(Request $request): JsonResponse
{
$request->validate([
'ids' => 'required|array|min:1',
'ids.*' => 'integer',
'gateway' => 'required|string|max:45',
]);
$pools = IpPool::whereIn('id', $request->ids)->get(['id', 'ip_address', 'module_network_id']);
if ($pools->isEmpty()) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy IP Pool nào'], 404);
}
DB::beginTransaction();
try {
IpPool::whereIn('id', $pools->pluck('id'))->update(['gateway' => $request->gateway]);
// Mirror into module_ip_address, grouped by network to keep this to a handful of queries.
foreach ($pools->groupBy('module_network_id') as $networkId => $group) {
ModuleIpAddress::whereIn('ip', $group->pluck('ip_address'))
->where('module_network_id', $networkId)
->update(['gateway' => $request->gateway]);
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['error' => true, 'message' => $e->getMessage()], 500);
}
return response()->json([
'error' => false,
'message' => "Đã cập nhật gateway cho {$pools->count()} IP",
'updated' => $pools->count(),
]);
}
public function bulkDelete(Request $request): JsonResponse
{
$request->validate([
@@ -354,6 +422,42 @@ class IpPoolController extends Controller
]);
}
public function import(Request $request): JsonResponse
{
$request->validate([
'file' => 'required|file|mimes:csv,txt|max:10240',
]);
$countBefore = IpPool::withTrashed()->count();
try {
Excel::import(new IpPoolImport(), $request->file('file'));
} catch (ValidationException $e) {
$errors = [];
foreach ($e->failures() as $failure) {
$errors[] = 'Dòng ' . $failure->row() . ': ' . implode(', ', $failure->errors());
}
return response()->json([
'error' => true,
'message' => 'Import thất bại',
'errors' => $errors,
], 422);
} catch (\Exception $e) {
return response()->json(['error' => true, 'message' => 'Import thất bại: ' . $e->getMessage()], 500);
}
$countAfter = IpPool::withTrashed()->count();
IpPool::resyncAllContacts();
return response()->json([
'error' => false,
'message' => 'Import thành công',
'created' => max(0, $countAfter - $countBefore),
]);
}
/**
* Backfill/repair the module_ip_address mirror for every ip_pools row.
* Existing mirror rows only get gateway/netmask refreshed — status/vm_name
+48 -9
View File
@@ -4,6 +4,7 @@ namespace App\Imports;
use App\Models\IpPool;
use App\Models\ModuleNetwork;
use Carbon\Carbon;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
@@ -13,17 +14,22 @@ use Maatwebsite\Excel\Concerns\WithValidation;
class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpserts, WithUpsertColumns
{
/** @var array<string, int> */
/** @var array<string, ModuleNetwork> */
private array $networkCache = [];
public function model(array $row)
{
$network = $this->resolveNetwork($row['ten_mang'] ?? null, $row['cidr'] ?? null);
return new IpPool([
// uuid has no DB default and must be set on insert; excluded from upsertColumns() so it's never overwritten
'uuid' => (string) Str::uuid(),
'ip_address' => $row['ip_address'],
'module_network_id' => $this->resolveNetworkId($row['ten_mang'] ?? null, $row['cidr'] ?? null),
'cidr' => $this->parsePrefixLength($row['cidr'] ?? null),
'module_network_id' => $network?->id,
// ip_pools.cidr is NOT NULL; fall back to the network's own prefix (or 24) when the row omits it
'cidr' => $this->parsePrefixLength($row['cidr'] ?? null)
?? $this->parsePrefixLength($network?->network)
?? 24,
'gateway' => $row['gateway'] ?? null,
'netmask' => $row['subnet_mask'] ?? null,
'owner_type' => $row['owner_type'] ?? null,
@@ -31,8 +37,8 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
'parent_ip' => $row['parent_ip'] ?? null,
'customer_name' => $row['ten_khach_hang'] ?? null,
'service_name' => $row['ten_dich_vu'] ?? null,
'allocated_at' => $row['ngay_cap_phat'] ?: null,
'revoked_at' => $row['ngay_thu_hoi'] ?: null,
'allocated_at' => $this->parseDate($row['ngay_cap_phat'] ?? null),
'revoked_at' => $this->parseDate($row['ngay_thu_hoi'] ?? null),
'allocation_status' => $row['trang_thai'] ?? null,
'contact_email' => $row['contact_email'] ?? null,
'contact_cc' => $row['contact_cc'] ?? null,
@@ -41,7 +47,7 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
'selling_price' => $row['selling_price'] ?: null,
'additional_info' => $row['thong_tin_bo_sung'] ?? null,
'last_email_type' => $row['last_email_type'] ?? null,
'last_email_at' => $row['last_email_at'] ?: null,
'last_email_at' => $this->parseDate($row['last_email_at'] ?? null),
'updated_by' => $row['updated_by'] ?? null,
'note' => $row['ghi_chu'] ?? null,
]);
@@ -49,16 +55,49 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
public function rules(): array
{
$multiEmail = function (string $attribute, mixed $value, \Closure $fail) {
foreach (preg_split('/[,;]+/', (string) $value) as $email) {
$email = trim($email);
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$fail("Email không hợp lệ: {$email}");
}
}
};
return [
'ten_mang' => 'required|string|max:255',
'cidr' => 'nullable|string|max:45',
'ip_address' => 'required|ip',
'contact_email' => 'nullable|email',
'contact_email' => ['nullable', 'string', $multiEmail],
'contact_cc' => ['nullable', 'string', $multiEmail],
'monthly_cost' => 'nullable|numeric',
'selling_price' => 'nullable|numeric',
];
}
/**
* CSV dates come as "dd/mm/yyyy" or "dd/mm/yyyy H:i:s" (Vietnamese sheet export).
*/
private function parseDate(?string $value): ?string
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
try {
return Carbon::createFromFormat('d/m/Y H:i:s', $value)->toDateTimeString();
} catch (\Throwable) {
// not a datetime, try date-only below
}
try {
return Carbon::createFromFormat('d/m/Y', $value)->startOfDay()->toDateTimeString();
} catch (\Throwable) {
return null;
}
}
/**
* CSV gives CIDR as "/22"; the ip_pools.cidr column only stores the prefix length.
*/
@@ -83,7 +122,7 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
];
}
private function resolveNetworkId(?string $name, ?string $cidr): ?int
private function resolveNetwork(?string $name, ?string $cidr): ?ModuleNetwork
{
if (!$name) {
return null;
@@ -99,6 +138,6 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
?? ($cidr ? ModuleNetwork::where('network', $cidr)->first() : null)
?? ModuleNetwork::create(['name' => $name, 'network' => $cidr]);
return $this->networkCache[$key] = $network->id;
return $this->networkCache[$key] = $network;
}
}
+64 -7
View File
@@ -13,16 +13,12 @@ class IpPool extends Model
use HasFactory, SoftDeletes, LogsActivity;
protected $fillable = [
'uuid',
'uuid',
'ip_address',
'gateway',
'cidr',
'netmask',
'module_network_id',
'gateway',
'cidr',
'netmask',
'status',
'owner_type',
'ip_role',
@@ -32,6 +28,7 @@ class IpPool extends Model
'allocated_at',
'revoked_at',
'allocation_status',
'service_id',
'contact_email',
'contact_cc',
'contact_phone',
@@ -51,9 +48,6 @@ class IpPool extends Model
'monthly_cost' => 'decimal:2',
'selling_price' => 'decimal:2',
];
'status',
'service_id',
];
protected static function boot(): void
{
@@ -64,6 +58,14 @@ class IpPool extends Model
$model->uuid = (string) Str::uuid();
}
});
// Keep ip_pool_contacts in sync whenever contact_email/contact_cc change via normal Eloquent saves.
// Bulk import (WithUpserts) bypasses this — call resyncAllContacts() after Excel::import() instead.
static::saved(function (self $model) {
if ($model->wasChanged(['contact_email', 'contact_cc']) || $model->wasRecentlyCreated) {
$model->syncContacts();
}
});
}
public function network()
@@ -80,4 +82,59 @@ class IpPool extends Model
{
return $this->hasMany(VirtualMachines::class, 'ip_pool_id');
}
public function contacts()
{
return $this->hasMany(IpPoolContact::class);
}
/**
* contact_email/contact_cc may hold multiple addresses separated by comma/semicolon
* (e.g. imported from Google Sheet). Split into a clean array, dropping invalid/empty entries.
*/
public static function splitEmails(?string $value): array
{
if (!$value) {
return [];
}
return collect(preg_split('/[,;]+/', $value))
->map(fn ($e) => trim($e))
->filter(fn ($e) => $e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL))
->unique()
->values()
->all();
}
public function syncContacts(): void
{
$rows = [];
foreach (self::splitEmails($this->contact_email) as $email) {
$rows[] = ['ip_pool_id' => $this->id, 'email' => $email, 'type' => 'to'];
}
foreach (self::splitEmails($this->contact_cc) as $email) {
$rows[] = ['ip_pool_id' => $this->id, 'email' => $email, 'type' => 'cc'];
}
$this->contacts()->delete();
if ($rows) {
IpPoolContact::insert($rows);
}
}
/**
* Full resync for bulk paths (CSV import) that bypass the saved() hook above.
*/
public static function resyncAllContacts(): void
{
self::withTrashed()
->where(function ($q) {
$q->whereNotNull('contact_email')->orWhereNotNull('contact_cc');
})
->chunkById(500, function ($pools) {
foreach ($pools as $pool) {
$pool->syncContacts();
}
});
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class IpPoolContact extends Model
{
protected $fillable = ['ip_pool_id', 'email', 'type'];
public function ipPool()
{
return $this->belongsTo(IpPool::class);
}
}