[], 'node' => ['tenant:id,tenant_code,tenant_name', 'deviceType:id,tenant_id,code,name,category,color,icon', 'parent:id,code,name'], 'link' => ['tenant:id,tenant_code,tenant_name', 'sourceNode.deviceType:id,code,name,color', 'targetNode.deviceType:id,code,name,color', 'sourcePort:id,node_id,name,port_number', 'targetPort:id,node_id,name,port_number'], ]; public function list(string $resource, array $filters, User $actor, ?int $tenantId) { $query = $this->model($resource)::query()->with(self::RELATIONS[$resource]); $this->scopeTenant($query, $resource, $actor, $tenantId); $sortable = [ 'device-type' => ['id', 'code', 'name', 'category', 'status', 'created_at'], 'node' => ['id', 'code', 'name', 'status', 'installed_at', 'created_at'], 'link' => ['id', 'code', 'name', 'link_type', 'status', 'installed_at', 'created_at'], ][$resource]; $sortBy = in_array($filters['sort_by'] ?? null, $sortable, true) ? $filters['sort_by'] : 'id'; return $query ->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status'])) ->when(! empty($filters['device_type_id']) && $resource === 'node', fn ($q) => $q->where('device_type_id', $filters['device_type_id'])) ->when(! empty($filters['link_type']) && $resource === 'link', fn ($q) => $q->where('link_type', $filters['link_type'])) ->when(! empty($filters['search']), fn ($q) => $q->where(function ($search) use ($filters) { $term = "%{$filters['search']}%"; $search->where('code', 'ILIKE', $term)->orWhere('name', 'ILIKE', $term); })) ->orderBy($sortBy, $filters['sort_direction'] ?? 'desc') ->paginate($filters['per_page'] ?? 10); } public function find(string $resource, int $id): Model { $relations = self::RELATIONS[$resource]; if ($resource === 'node') { $relations = [...$relations, 'ports']; } return $this->model($resource)::with($relations)->findOrFail($id); } public function create(string $resource, array $data, User $actor, ?int $tenantId): Model { return DB::transaction(function () use ($resource, $data, $actor, $tenantId) { $data = $this->prepare($resource, $data, $actor, $tenantId); $model = $this->model($resource)::create($data); return $this->find($resource, $model->id); }); } public function update(string $resource, Model $model, array $data, User $actor): Model { return DB::transaction(function () use ($resource, $model, $data, $actor) { if (! $actor->isMasterAdmin()) { unset($data['tenant_id']); } $data['tenant_id'] ??= $model->tenant_id; $data = $this->prepare($resource, $data, $actor, $model->tenant_id, $model); $model->update($data); return $this->find($resource, $model->id); }); } public function delete(string $resource, Model $model): void { if ($resource === 'device-type' && ($model->is_system || $model->nodes()->exists())) { throw ValidationException::withMessages(['device_type' => 'Kategori sistem atau kategori yang digunakan tidak dapat dihapus.']); } if ($resource === 'node' && ($model->incomingLinks()->exists() || $model->outgoingLinks()->exists())) { throw ValidationException::withMessages(['node' => 'Perangkat masih terhubung dengan jalur jaringan.']); } if ($resource === 'node' && TopologyCustomerConnection::where('odp_node_id', $model->id)->where('status', 'active')->exists()) { throw ValidationException::withMessages(['node' => 'Perangkat masih melayani customer aktif.']); } $model->delete(); } public function options(User $actor, ?int $tenantId): array { $types = TopologyDeviceType::query(); $nodes = TopologyNode::query()->with('deviceType:id,code,name,color'); $this->scopeTenant($types, 'device-type', $actor, $tenantId); $this->scopeTenant($nodes, 'node', $actor, $tenantId); return [ 'device_types' => $types->where('status', 'active')->orderBy('name')->get(), 'nodes' => $nodes->where('status', 'active')->orderBy('name')->get(), ]; } public function ports(TopologyNode $node): Collection { return $node->ports()->get(); } public function createPort(TopologyNode $node, array $data): TopologyPort { if (! $node->deviceType->can_have_ports) { throw ValidationException::withMessages(['node_id' => 'Kategori perangkat ini tidak mendukung port.']); } return $node->ports()->create([...$data, 'tenant_id' => $node->tenant_id]); } public function updatePort(TopologyPort $port, array $data): TopologyPort { $port->update($data); return $port->fresh(); } public function deletePort(TopologyPort $port): void { if (TopologyLink::where('source_port_id', $port->id)->orWhere('target_port_id', $port->id)->exists()) { throw ValidationException::withMessages(['port' => 'Port masih digunakan oleh jalur jaringan.']); } $port->delete(); } public function map(array $filters, User $actor, ?int $tenantId): array { $nodes = TopologyNode::query()->with('deviceType:id,code,name,color,icon'); $links = TopologyLink::query()->with(['sourceNode:id,latitude,longitude', 'targetNode:id,latitude,longitude']); $this->scopeTenant($nodes, 'node', $actor, $tenantId); $this->scopeTenant($links, 'link', $actor, $tenantId); if (! empty($filters['bbox'])) { [$minLng, $minLat, $maxLng, $maxLat] = array_map('floatval', explode(',', $filters['bbox'])); $nodes->whereBetween('longitude', [$minLng, $maxLng])->whereBetween('latitude', [$minLat, $maxLat]); $links->where(function ($query) use ($minLng, $minLat, $maxLng, $maxLat) { $query->whereHas('sourceNode', fn ($q) => $q->whereBetween('longitude', [$minLng, $maxLng])->whereBetween('latitude', [$minLat, $maxLat])) ->orWhereHas('targetNode', fn ($q) => $q->whereBetween('longitude', [$minLng, $maxLng])->whereBetween('latitude', [$minLat, $maxLat])); }); } $nodeRows = $nodes->where('status', '!=', 'inactive')->get(); $linkRows = $links->where('status', '!=', 'inactive')->get(); return [ 'nodes' => [ 'type' => 'FeatureCollection', 'features' => $nodeRows->map(fn ($node) => [ 'type' => 'Feature', 'id' => $node->id, 'geometry' => ['type' => 'Point', 'coordinates' => [(float) $node->longitude, (float) $node->latitude]], 'properties' => [ 'id' => $node->id, 'code' => $node->code, 'name' => $node->name, 'status' => $node->status, 'device_type' => $node->deviceType?->code, 'color' => $node->deviceType?->color ?? '#2f74c0', ], ])->values(), ], 'links' => [ 'type' => 'FeatureCollection', 'features' => $linkRows->map(function ($link) { $geometry = $link->path_geojson ?: [ 'type' => 'LineString', 'coordinates' => [ [(float) $link->sourceNode->longitude, (float) $link->sourceNode->latitude], [(float) $link->targetNode->longitude, (float) $link->targetNode->latitude], ], ]; return [ 'type' => 'Feature', 'id' => $link->id, 'geometry' => $geometry, 'properties' => [ 'id' => $link->id, 'code' => $link->code, 'name' => $link->name, 'status' => $link->status, 'link_type' => $link->link_type, 'core_count' => $link->core_count, ], ]; })->values(), ], ]; } public function trace(TopologyNode $node, string $direction): array { $visited = []; $levels = [[$node->id]]; while ($levels[count($levels) - 1] && count($levels) <= 100) { $ids = $levels[count($levels) - 1]; $visited = [...$visited, ...$ids]; $column = $direction === 'upstream' ? 'target_node_id' : 'source_node_id'; $select = $direction === 'upstream' ? 'source_node_id' : 'target_node_id'; $next = TopologyLink::where('tenant_id', $node->tenant_id) ->whereIn($column, $ids) ->where('status', 'active') ->pluck($select) ->reject(fn ($id) => in_array($id, $visited)) ->unique() ->values() ->all(); if (! $next) { break; } $levels[] = $next; } $nodes = TopologyNode::with('deviceType:id,code,name,color') ->whereIn('id', collect($levels)->flatten()->unique()) ->get()->keyBy('id'); return collect($levels)->map(fn ($ids, $level) => [ 'level' => $level, 'nodes' => collect($ids)->map(fn ($id) => $nodes->get($id))->filter()->values(), ])->all(); } public function connectCustomer(array $data, User $actor, ?int $tenantId): TopologyCustomerConnection { $customer = Customer::findOrFail($data['customer_id']); $node = TopologyNode::with('deviceType')->findOrFail($data['odp_node_id']); $effectiveTenant = $actor->isMasterAdmin() ? $customer->tenant_id : $tenantId; if ((int) $customer->tenant_id !== (int) $effectiveTenant || (int) $node->tenant_id !== (int) $effectiveTenant || $node->deviceType?->code !== 'ODP') { throw ValidationException::withMessages(['odp_node_id' => 'Customer dan ODP harus berasal dari tenant yang sama dan perangkat harus bertipe ODP.']); } if (! empty($data['odp_port_id']) && ! TopologyPort::whereKey($data['odp_port_id'])->where('node_id', $node->id)->exists()) { throw ValidationException::withMessages(['odp_port_id' => 'Port bukan milik ODP yang dipilih.']); } TopologyCustomerConnection::where('customer_id', $customer->id)->where('status', 'active') ->update(['status' => 'inactive', 'disconnected_at' => now()]); $customer->update(['topology_id' => $node->id]); return TopologyCustomerConnection::create([ ...$data, 'tenant_id' => $effectiveTenant, 'connected_at' => $data['connected_at'] ?? now(), 'status' => 'active', ])->load(['customer:id,customer_code,name', 'odpNode:id,code,name', 'odpPort:id,name,port_number']); } public function customerPath(Customer $customer): array { $connection = TopologyCustomerConnection::with(['odpNode.deviceType', 'odpPort']) ->where('customer_id', $customer->id) ->where('status', 'active') ->first(); if (! $connection) { throw ValidationException::withMessages(['customer_id' => 'Customer belum terhubung ke ODP.']); } return [ 'customer' => $customer->only(['id', 'customer_code', 'name']), 'connection' => $connection, 'path' => $this->trace($connection->odpNode, 'upstream'), ]; } private function prepare(string $resource, array $data, User $actor, ?int $tenantId, ?Model $existing = null): array { if ($resource === 'device-type') { $data['tenant_id'] = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? null) : $tenantId; $data['is_system'] = $existing?->is_system ?? false; return $data; } $data['tenant_id'] = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId; if (! $data['tenant_id']) { throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']); } if ($resource === 'node') { $type = TopologyDeviceType::whereKey($data['device_type_id'] ?? $existing?->device_type_id) ->where(fn ($q) => $q->whereNull('tenant_id')->orWhere('tenant_id', $data['tenant_id'])) ->first(); if (! $type) { throw ValidationException::withMessages(['device_type_id' => 'Kategori perangkat tidak tersedia untuk tenant ini.']); } if (! empty($data['parent_node_id']) && ! TopologyNode::whereKey($data['parent_node_id'])->where('tenant_id', $data['tenant_id'])->exists()) { throw ValidationException::withMessages(['parent_node_id' => 'Parent perangkat bukan milik tenant yang sama.']); } } if ($resource === 'link') { $this->validateLink($data, $existing); } $data['updated_by'] = $actor->id; if (! $existing) { $data['created_by'] = $actor->id; } return $data; } private function validateLink(array $data, ?Model $existing): void { $sourceId = (int) ($data['source_node_id'] ?? $existing?->source_node_id); $targetId = (int) ($data['target_node_id'] ?? $existing?->target_node_id); $tenantId = (int) $data['tenant_id']; $nodes = TopologyNode::with('deviceType')->whereIn('id', [$sourceId, $targetId])->where('tenant_id', $tenantId)->get()->keyBy('id'); if ($sourceId === $targetId || $nodes->count() !== 2) { throw ValidationException::withMessages(['target_node_id' => 'Node asal dan tujuan harus berbeda dan berasal dari tenant yang sama.']); } $rule = TopologyConnectionRule::where(fn ($q) => $q->whereNull('tenant_id')->orWhere('tenant_id', $tenantId)) ->where('source_device_type_id', $nodes[$sourceId]->device_type_id) ->where('target_device_type_id', $nodes[$targetId]->device_type_id) ->orderByDesc('tenant_id') ->first(); if ($rule && ! $rule->allowed) { throw ValidationException::withMessages(['target_node_id' => 'Hubungan antar kategori perangkat ini tidak diizinkan.']); } if ($rule?->max_connections) { $connectionCount = TopologyLink::where('source_node_id', $sourceId) ->when($existing, fn ($q) => $q->whereKeyNot($existing->id)) ->count(); if ($connectionCount >= $rule->max_connections) { throw ValidationException::withMessages(['source_node_id' => 'Batas maksimal koneksi perangkat asal sudah tercapai.']); } } $duplicate = TopologyLink::where('source_node_id', $sourceId) ->where('target_node_id', $targetId) ->when($existing, fn ($q) => $q->whereKeyNot($existing->id)) ->where(function ($query) use ($data, $existing) { $sourcePortId = $data['source_port_id'] ?? $existing?->source_port_id; $targetPortId = $data['target_port_id'] ?? $existing?->target_port_id; $sourcePortId ? $query->where('source_port_id', $sourcePortId) : $query->whereNull('source_port_id'); $targetPortId ? $query->where('target_port_id', $targetPortId) : $query->whereNull('target_port_id'); }) ->exists(); if ($duplicate) { throw ValidationException::withMessages(['target_node_id' => 'Jalur dengan endpoint yang sama sudah tersedia.']); } foreach (['source_port_id' => $sourceId, 'target_port_id' => $targetId] as $field => $nodeId) { $portId = $data[$field] ?? $existing?->{$field}; if ($portId) { $port = TopologyPort::whereKey($portId)->where('node_id', $nodeId)->where('tenant_id', $tenantId)->first(); if (! $port) { throw ValidationException::withMessages([$field => 'Port tidak sesuai dengan node yang dipilih.']); } $used = TopologyLink::where(fn ($q) => $q->where('source_port_id', $portId)->orWhere('target_port_id', $portId)) ->when($existing, fn ($q) => $q->whereKeyNot($existing->id)) ->count(); if ($used >= $port->capacity) { throw ValidationException::withMessages([$field => 'Kapasitas port sudah terpakai penuh.']); } } } if ($this->hasDirectedPath($targetId, $sourceId, $tenantId, $existing?->id)) { throw ValidationException::withMessages(['target_node_id' => 'Jalur ini membentuk loop topologi.']); } } private function hasDirectedPath(int $from, int $to, int $tenantId, ?int $ignoreLink): bool { $queue = [$from]; $visited = []; while ($queue) { $current = array_shift($queue); if ($current === $to) { return true; } if (isset($visited[$current])) { continue; } $visited[$current] = true; $next = TopologyLink::where('tenant_id', $tenantId) ->where('source_node_id', $current) ->when($ignoreLink, fn ($q) => $q->whereKeyNot($ignoreLink)) ->pluck('target_node_id') ->all(); $queue = [...$queue, ...$next]; } return false; } private function scopeTenant($query, string $resource, User $actor, ?int $tenantId): void { if ($resource === 'device-type') { if ($actor->isMasterAdmin() && ! $tenantId) { return; } $query->where(function ($q) use ($tenantId) { $q->whereNull('tenant_id') ->orWhere('tenant_id', $tenantId); }); return; } $query->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId)) ->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId)); } private function model(string $resource): string { return match ($resource) { 'device-type' => TopologyDeviceType::class, 'node' => TopologyNode::class, 'link' => TopologyLink::class, default => throw ValidationException::withMessages(['resource' => 'Resource topologi tidak valid.']), }; } }