422 lines
19 KiB
PHP
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.']),
|
|
};
|
|
}
|
|
}
|