1
0
Files
services_core/app/Services/Topology/TopologyService.php
T
2026-07-31 13:57:11 +07:00

422 lines
19 KiB
PHP

<?php
namespace App\Services\Topology;
use App\Models\Customer;
use App\Models\TopologyConnectionRule;
use App\Models\TopologyCustomerConnection;
use App\Models\TopologyDeviceType;
use App\Models\TopologyLink;
use App\Models\TopologyNode;
use App\Models\TopologyPort;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class TopologyService
{
private const RELATIONS = [
'device-type' => [],
'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.']),
};
}
}