forked from admin/services_core
Giant Update
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
<?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.']),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user