big update all menus

This commit is contained in:
Wian Drs
2026-08-07 09:16:13 +07:00
parent 75342dc7b7
commit e7575ba1c0
101 changed files with 3451 additions and 89 deletions
+2 -1
View File
@@ -29,7 +29,7 @@ class AccessService
string $permission,
?int $tenantId
): bool {
if ($user->isMasterAdmin()) {
if ($user->isMasterAdmin() || $user->isTenantOwner($tenantId)) {
return true;
}
@@ -40,6 +40,7 @@ class AccessService
'can_update',
'can_delete',
'can_approve',
'can_activate',
'can_export',
];
+126 -12
View File
@@ -10,12 +10,24 @@ use App\Models\NasPackageProfile;
use App\Models\StoredFile;
use App\Models\User;
use App\Models\WilayahDesa;
use App\Models\TopologyNode;
use App\Models\TopologyPort;
use App\Models\TopologyCustomerConnection;
use App\Models\Ticket;
use App\Models\TicketLog;
use App\Models\TicketType;
use Illuminate\Support\Facades\DB;
use App\Services\Notification\NotificationManager;
use App\Services\Nas\CustomerRouterOsProvisioningService;
use Illuminate\Validation\ValidationException;
class CustomerService
{
public function __construct(protected NotificationManager $notifications) {}
public function __construct(
protected NotificationManager $notifications,
protected CustomerRouterOsProvisioningService $routerOsProvisioning,
protected \App\Services\Ticket\TicketService $ticketService,
) {}
private const BASE_RELATIONS = [
'tenant:id,tenant_code,tenant_name',
@@ -26,6 +38,9 @@ class CustomerService
'packageProfile:id,tenant_id,name,service_type,download_kbps,upload_kbps,price',
'nasMikrotik:id,tenant_id,name,connection_type,host,status',
'billingProfile:id,tenant_id,name,schedule_type,invoice_day,send_day,warning_day,isolation_day,invoice_days_before,send_days_before,warning_days_before,isolation_days_after,tax_type,tax_rate,status',
'salesUser:id,name,username',
'activeTopologyConnection.odpNode:id,code,name',
'activeTopologyConnection.odpPort:id,node_id,name,port_number,status',
];
private const DETAIL_RELATIONS = [
@@ -79,19 +94,25 @@ class CustomerService
if (! $data['tenant_id']) {
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
}
$data['customer_code'] = filled($data['customer_code'] ?? null)
? $data['customer_code']
: $this->nextCode((int) $data['tenant_id']);
unset($data['customer_code'], $data['external_username'], $data['order_stage']);
$data['customer_code'] = null;
$data['source'] ??= 'manual';
$data['status'] = $data['source'] === 'manual' ? 'order' : 'unmanaged';
$data['order_stage'] ??= 'registration';
$this->validateTenantRelations($data);
$this->validateWilayah($data);
$customer = Customer::create($data);
$this->syncImages($customer, $images, $actor);
$this->syncPrimaryContacts($customer);
$customer = DB::transaction(function () use ($data,$images,$actor) {
$customer = Customer::create($data);
if (isset($data['topology_id'], $data['topology_port_number'])) {
$this->syncTopologyReservation($customer, (int) $data['topology_id'], (int) $data['topology_port_number']);
}
$this->syncImages($customer, $images, $actor);
$this->syncPrimaryContacts($customer);
return $customer;
});
$customer->load('tenant:id,tenant_name');
$this->createInstallationTicketWhenRequired($customer, $actor);
$this->notifications->emit(
'customer.registration',
$customer,
@@ -128,7 +149,11 @@ class CustomerService
$this->validateTenantRelations($data);
$this->validateWilayah($data);
unset($data['status']);
$customer->update($data);
unset($data['customer_code'], $data['external_username'], $data['order_stage']);
DB::transaction(function () use ($customer,$data) {
$customer->update($data);
if (isset($data['topology_id'],$data['topology_port_number'])) $this->syncTopologyReservation($customer,(int)$data['topology_id'],(int)$data['topology_port_number']);
});
$this->syncPrimaryContacts($customer);
if ($hasImages) {
$this->syncImages($customer, $images, $actor);
@@ -137,8 +162,22 @@ class CustomerService
return $customer->fresh()->load(self::DETAIL_RELATIONS);
}
public function activate(Customer $customer, array $data): Customer
public function activate(Customer $customer, array $data, User $actor): Customer
{
$source = $data['activation_source'];
unset($data['activation_source']);
$mode = \App\Models\TenantInstallationSetting::firstOrCreate(
['tenant_id' => $customer->tenant_id],
['activation_mode' => 'list_order']
)->activation_mode;
if ($source !== $mode) {
throw ValidationException::withMessages([
'activation_source' => $mode === 'ticket'
? 'Tenant mengatur aktivasi customer hanya melalui Ticket.'
: 'Tenant mengatur aktivasi customer hanya melalui List Order.',
]);
}
$customer = $this->ensureActivationCode($customer);
$data['tenant_id'] = $customer->tenant_id;
$this->validateTenantRelations($data);
if (! $customer->desa_id || ! $customer->installation_address) {
@@ -146,13 +185,21 @@ class CustomerService
'installation_address' => 'Alamat pemasangan dan wilayah desa wajib lengkap sebelum aktivasi.',
]);
}
$this->routerOsProvisioning->create($customer, $data);
unset($data['external_password']);
DB::transaction(function () use ($customer,$data) {
DB::statement('SELECT pg_advisory_xact_lock(?)', [(int)$customer->tenant_id]);
$customer->update([
...$data,
'customer_code' => $customer->customer_code,
'status' => 'active',
'order_stage' => 'ready_activation',
'activated_at' => now(),
'inactivated_at' => null,
]);
$connection=$customer->topologyConnections()->where('status','reserved')->latest('id')->first();
if($connection){$connection->update(['status'=>'active','connected_at'=>now()]);$connection->odpPort()->update(['status'=>'used']);}
});
$customer->load('tenant:id,tenant_name');
$this->notifications->emit(
'customer.activation',
@@ -168,6 +215,9 @@ class CustomerService
public function deactivate(Customer $customer): Customer
{
if ($customer->nasMikrotik?->connection_type === 'api') {
$this->routerOsProvisioning->setEnabled($customer, false);
}
$customer->update(['status' => 'inactive', 'inactivated_at' => now()]);
return $customer->fresh()->load(self::DETAIL_RELATIONS);
@@ -175,6 +225,7 @@ class CustomerService
public function trash(Customer $customer): void
{
$this->releaseTopologyPort($customer);
$customer->delete();
}
@@ -187,6 +238,10 @@ class CustomerService
public function forceDelete(Customer $customer): void
{
if ($customer->status !== 'order' && $customer->nasAccount()->exists() && $customer->nasMikrotik?->connection_type === 'api') {
$this->routerOsProvisioning->delete($customer);
}
$this->releaseTopologyPort($customer);
$customer->forceDelete();
}
@@ -205,14 +260,70 @@ class CustomerService
->get(['id', 'tenant_id', 'name', 'connection_type', 'host']),
'billing_profiles' => $scope(BillingProfile::query())
->get(['id', 'tenant_id', 'name', 'schedule_type', 'invoice_day', 'send_day', 'warning_day', 'isolation_day', 'invoice_days_before', 'send_days_before', 'warning_days_before', 'isolation_days_after', 'tax_type', 'tax_rate']),
'topology_odps' => $scope(TopologyNode::query()->whereHas('deviceType', fn($q)=>$q->whereRaw('UPPER(code) = ?', ['ODP'])))
->get(['id','tenant_id','code','name','capacity']),
'sales_users' => User::query()->where('status','active')->whereHas('userTenants', fn($q)=>$q->when($tenantId,fn($x)=>$x->where('tenant_id',$tenantId))->where('role','staff'))
->with(['userTenants'=>fn($q)=>$q->when($tenantId,fn($x)=>$x->where('tenant_id',$tenantId))->select('id','user_id','tenant_id','role','department')])->orderBy('name')->get(['id','name','username']),
'installation_setting' => $tenantId
? \App\Models\TenantInstallationSetting::firstOrCreate(['tenant_id' => $tenantId], ['activation_mode' => 'list_order'])
: null,
];
}
private function nextCode(int $tenantId): string
public function availableOdpPorts(int $nodeId, User $actor, ?int $tenantId, ?int $customerId=null)
{
$next = ((int) Customer::withTrashed()->where('tenant_id', $tenantId)->max('id')) + 1;
$node=TopologyNode::whereKey($nodeId)->whereHas('deviceType',fn($q)=>$q->whereRaw('UPPER(code) = ?', ['ODP']))->when(!$actor->isMasterAdmin(),fn($q)=>$q->where('tenant_id',$tenantId))->firstOrFail();
if((int)$node->capacity<1) throw ValidationException::withMessages(['topology_id'=>'Capacity ODP belum dikonfigurasi.']);
$currentNumber=$customerId?Customer::whereKey($customerId)->where('tenant_id',$node->tenant_id)->where('topology_id',$node->id)->value('topology_port_number'):null;
$used=TopologyPort::where('node_id',$node->id)->when($currentNumber,fn($q)=>$q->where('port_number','!=',$currentNumber))->pluck('port_number')->map(fn($number)=>(int)$number)->all();
return collect(range(1,(int)$node->capacity))->reject(fn($number)=>in_array($number,$used,true))->map(fn($number)=>['port_number'=>$number,'name'=>"Port {$number}",'node_id'=>$node->id])->values();
}
return 'CUST-'.str_pad((string) $next, 6, '0', STR_PAD_LEFT);
public function ensureActivationCode(Customer $customer): Customer
{
if ($customer->customer_code) return $customer;
DB::transaction(function () use ($customer) {
DB::statement('SELECT pg_advisory_xact_lock(?)', [(int) $customer->tenant_id]);
$locked = Customer::whereKey($customer->id)->lockForUpdate()->firstOrFail();
if (! $locked->customer_code) $locked->update(['customer_code' => $this->nextActivationCode((int) $locked->tenant_id)]);
});
return $customer->fresh();
}
private function createInstallationTicketWhenRequired(Customer $customer, User $actor): void
{
$mode = \App\Models\TenantInstallationSetting::firstOrCreate(['tenant_id'=>$customer->tenant_id],['activation_mode'=>'list_order'])->activation_mode;
if ($mode !== 'ticket' || $customer->status !== 'order') return;
DB::transaction(function () use ($customer, $actor) {
if (Ticket::where('customer_id',$customer->id)->where('source','new_installation')->whereNotIn('status',['cancelled','rejected'])->exists()) return;
$type=TicketType::firstOrCreate(['code'=>'NEW-INSTALLATION'],['name'=>'Pemasangan Baru','need_approval'=>true,'sla_minutes'=>1440,'require_photo'=>true,'require_material'=>false,'need_customer'=>true,'need_incident_type'=>false]);
$ticket=Ticket::create(['ticket_no'=>$this->ticketService->generateNo(),'ticket_type_id'=>$type->id,'tenant_id'=>$customer->tenant_id,'customer_id'=>$customer->id,'source'=>'new_installation','installation_step'=>'documentation','title'=>'Pemasangan Baru - '.$customer->name,'description'=>'Dibuat otomatis dari registrasi List Order.','priority'=>'medium','status'=>'open','created_by'=>$actor->id]);
TicketLog::create(['ticket_id'=>$ticket->id,'user_id'=>$actor->id,'activity'=>'Ticket Pemasangan Dibuat','notes'=>'Dibuat otomatis dari registrasi customer.','created_at'=>now()]);
});
}
private function nextActivationCode(int $tenantId): string { $time=now(); do{$code=$time->format('YmdHis');$exists=Customer::withTrashed()->where('tenant_id',$tenantId)->where('customer_code',$code)->exists();if($exists)$time->addSecond();}while($exists);return $code; }
private function syncTopologyReservation(Customer $customer,int $nodeId,int $portNumber): void
{
$node=TopologyNode::whereKey($nodeId)->where('tenant_id',$customer->tenant_id)->whereHas('deviceType',fn($q)=>$q->whereRaw('UPPER(code) = ?', ['ODP']))->lockForUpdate()->firstOrFail();
if($portNumber<1||$portNumber>(int)$node->capacity) throw ValidationException::withMessages(['topology_port_number'=>"Nomor port harus antara 1 sampai {$node->capacity}."]);
$current=$customer->topologyConnections()->whereIn('status',['reserved','active'])->with('odpPort')->first();
if($current && (int)$current->odp_node_id===$node->id && (int)$current->odpPort?->port_number===$portNumber) {
$current->odpPort->update(['name'=>"Port {$portNumber} - {$customer->name}",'direction'=>'output']);
return;
}
if(TopologyPort::where('node_id',$node->id)->where('port_number',$portNumber)->exists()) throw ValidationException::withMessages(['topology_port_number'=>'Port ODP sudah digunakan customer lain.']);
if($current){$oldPort=$current->odpPort;$current->delete();$oldPort?->delete();}
$port=TopologyPort::create(['tenant_id'=>$customer->tenant_id,'node_id'=>$node->id,'name'=>"Port {$portNumber} - {$customer->name}",'port_number'=>$portNumber,'port_type'=>'fiber','direction'=>'output','capacity'=>1,'status'=>$customer->status==='active'?'used':'reserved']);
TopologyCustomerConnection::create(['customer_id'=>$customer->id,'odp_port_id'=>$port->id,'tenant_id'=>$customer->tenant_id,'odp_node_id'=>$node->id,'status'=>$customer->status==='active'?'active':'reserved']);
}
private function releaseTopologyPort(Customer $customer): void
{
DB::transaction(function () use ($customer) {
$customer->topologyConnections()->whereIn('status',['reserved','active'])->with('odpPort')->get()->each(function($connection){$port=$connection->odpPort;$connection->delete();$port?->delete();});
});
}
private function validateTenantRelations(array $data): void
@@ -227,6 +338,9 @@ class CustomerService
if (! empty($data['billing_profile_id']) && ! BillingProfile::whereKey($data['billing_profile_id'])->where('tenant_id', $tenantId)->exists()) {
throw ValidationException::withMessages(['billing_profile_id' => 'Profile tagihan bukan milik tenant customer.']);
}
if (! empty($data['sales_user_id']) && ! User::whereKey($data['sales_user_id'])->where('status','active')->whereHas('userTenants',fn($q)=>$q->where('tenant_id',$tenantId)->where('role','staff'))->exists()) {
throw ValidationException::withMessages(['sales_user_id'=>'Sales/penanggung jawab bukan staff aktif tenant customer.']);
}
}
private function validateWilayah(array $data): void
+9
View File
@@ -119,6 +119,7 @@ class MenuGroupService
DB::raw('COALESCE(mgm.can_update, false) as can_update'),
DB::raw('COALESCE(mgm.can_delete, false) as can_delete'),
DB::raw('COALESCE(mgm.can_approve, false) as can_approve'),
DB::raw('COALESCE(mgm.can_activate, false) as can_activate'),
DB::raw('COALESCE(mgm.can_export, false) as can_export')
)
->orderBy('m.sort_order')
@@ -141,6 +142,7 @@ class MenuGroupService
'can_update' => (bool) $row->can_update,
'can_delete' => (bool) $row->can_delete,
'can_approve' => (bool) $row->can_approve,
'can_activate' => (bool) $row->can_activate,
'can_export' => (bool) $row->can_export,
],
'available_permissions' => [
@@ -149,6 +151,7 @@ class MenuGroupService
'can_update' => true,
'can_delete' => true,
'can_approve' => true,
'can_activate' => true,
'can_export' => true,
],
];
@@ -191,6 +194,7 @@ class MenuGroupService
'can_update' => (bool) ($menu['can_update'] ?? false),
'can_delete' => (bool) ($menu['can_delete'] ?? false),
'can_approve' => (bool) ($menu['can_approve'] ?? false),
'can_activate' => (bool) ($menu['can_activate'] ?? false),
'can_export' => (bool) ($menu['can_export'] ?? false),
'updated_at' => now(),
'created_at' => now(),
@@ -229,6 +233,7 @@ class MenuGroupService
DB::raw('COALESCE(mgm.can_update, false) as can_update'),
DB::raw('COALESCE(mgm.can_delete, false) as can_delete'),
DB::raw('COALESCE(mgm.can_approve, false) as can_approve'),
DB::raw('COALESCE(mgm.can_activate, false) as can_activate'),
DB::raw('COALESCE(mgm.can_export, false) as can_export')
)
->orderBy('m.sort_order')
@@ -249,6 +254,7 @@ class MenuGroupService
'can_update' => (bool) $row->can_update,
'can_delete' => (bool) $row->can_delete,
'can_approve' => (bool) $row->can_approve,
'can_activate' => (bool) $row->can_activate,
'can_export' => (bool) $row->can_export,
],
'available_permissions' => $available,
@@ -278,6 +284,7 @@ class MenuGroupService
->orWhere('mgm.can_update', true)
->orWhere('mgm.can_delete', true)
->orWhere('mgm.can_approve', true)
->orWhere('mgm.can_activate', true)
->orWhere('mgm.can_export', true);
})
->distinct()
@@ -304,6 +311,7 @@ class MenuGroupService
DB::raw('MAX(CASE WHEN mgm.can_update THEN 1 ELSE 0 END) as can_update'),
DB::raw('MAX(CASE WHEN mgm.can_delete THEN 1 ELSE 0 END) as can_delete'),
DB::raw('MAX(CASE WHEN mgm.can_approve THEN 1 ELSE 0 END) as can_approve'),
DB::raw('MAX(CASE WHEN mgm.can_activate THEN 1 ELSE 0 END) as can_activate'),
DB::raw('MAX(CASE WHEN mgm.can_export THEN 1 ELSE 0 END) as can_export')
)
->get()
@@ -313,6 +321,7 @@ class MenuGroupService
'can_update' => (bool) $row->can_update,
'can_delete' => (bool) $row->can_delete,
'can_approve' => (bool) $row->can_approve,
'can_activate' => (bool) $row->can_activate,
'can_export' => (bool) $row->can_export,
]])
->all();
@@ -0,0 +1,40 @@
<?php
namespace App\Services\Nas;
use App\Models\CustomerNasAccount;
use App\Models\NasPackageProfile;
class CustomerAccountReconciliationService
{
public function __construct(protected RouterOsApiService $routerOs) {}
public function reconcile(CustomerNasAccount $account): void
{
$account->load(['mikrotik','packageProfile']);
if ($account->mikrotik->connection_type !== 'api') return;
$state = $this->routerOs->inspectAccount($account->mikrotik,$account->service_type,$account->username);
$active = $state['active'];
$uptime = max(
$this->durationSeconds($active['uptime'] ?? ''),
$this->durationSeconds($state['account']['uptime'] ?? '')
);
$bytes = max(
(int) ($active['bytes-in'] ?? 0) + (int) ($active['bytes-out'] ?? 0),
(int) ($state['account']['bytes-in'] ?? 0) + (int) ($state['account']['bytes-out'] ?? 0)
);
$updates=['remote_state'=>$state,'used_uptime_seconds'=>$uptime,'used_bytes'=>$bytes,'last_reconciled_at'=>now(),'last_sync_error'=>null];
if ($active && ! $account->first_login_at) {
$updates['first_login_at']=now();
if ($account->packageProfile->validity_start === 'first_login') { $updates['validity_started_at']=now(); $updates['expires_at']=$this->expiry($account->packageProfile); }
}
$expires=$updates['expires_at'] ?? $account->expires_at;
$expiresWithGrace = $expires?->copy()->addMinutes((int) $account->packageProfile->grace_period_minutes);
$limitReached=($expiresWithGrace && now()->gte($expiresWithGrace)) || ($account->packageProfile->uptime_limit_seconds && $uptime >= $account->packageProfile->uptime_limit_seconds) || ($account->packageProfile->data_limit_bytes && $bytes >= $account->packageProfile->data_limit_bytes);
if ($limitReached && $account->status === 'active') {
$this->routerOs->setEnabled($account->mikrotik,$account->service_type,$account->username,false);
$this->routerOs->disconnectAccount($account->mikrotik,$account->service_type,$account->username);
$updates['status']='expired';
}
$account->update($updates);
}
private function durationSeconds(string $value): int { preg_match_all('/(\d+)(w|d|h|m|s)/',$value,$m,PREG_SET_ORDER); $map=['w'=>604800,'d'=>86400,'h'=>3600,'m'=>60,'s'=>1]; return array_sum(array_map(fn($x)=>(int)$x[1]*$map[$x[2]],$m)); }
private function expiry(NasPackageProfile $p) { if(!$p->validity_value||!$p->validity_unit)return null; return match($p->validity_unit){'minutes'=>now()->addMinutes($p->validity_value),'hours'=>now()->addHours($p->validity_value),'days'=>now()->addDays($p->validity_value),'months'=>now()->addMonths($p->validity_value),default=>null}; }
}
@@ -0,0 +1,160 @@
<?php
namespace App\Services\Nas;
use App\Models\Customer;
use App\Models\CustomerNasAccount;
use App\Models\NasMikrotik;
use App\Models\NasPackageProfile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Throwable;
use Illuminate\Support\Carbon;
class CustomerRouterOsProvisioningService
{
public function __construct(
protected RouterOsApiService $routerOs,
protected PackageProfileDeploymentService $deployments,
) {}
public function create(Customer $customer, array $data): CustomerNasAccount
{
[$mikrotik, $profile] = $this->resources($customer, $data);
if ($mikrotik->connection_type !== 'api') {
throw ValidationException::withMessages(['nas_mikrotik_id' => 'Tahap ini hanya mendukung Mikrotik dengan koneksi API.']);
}
$target = $this->deployments->assertSynced($profile, $mikrotik);
$username = trim((string) ($data['external_username'] ?? ''));
$password = (string) ($data['external_password'] ?? '');
if ($username === '' || $password === '') {
throw ValidationException::withMessages(['external_username' => 'Username dan password RouterOS wajib diisi.']);
}
$remote = $this->routerOs->createAccount($mikrotik, $profile->service_type, [
'username' => $username,
'password' => $password,
'profile' => $target->external_profile_name,
'comment' => $this->comment($customer),
'disabled' => 'no',
]);
try {
return DB::transaction(fn () => CustomerNasAccount::create([
'tenant_id' => $customer->tenant_id,
'customer_id' => $customer->id,
'nas_mikrotik_id' => $mikrotik->id,
'nas_package_profile_id' => $profile->id,
'nas_package_profile_target_id' => $target->id,
'service_type' => $profile->service_type,
'usage_mode' => $profile->usage_mode,
'username' => $username,
'password' => $password,
'mac_address' => $data['mac_address'] ?? null,
'serial_number' => $data['serial_number'] ?? null,
'assigned_ip' => $data['assigned_ip'] ?? null,
'remote_id' => $remote['.id'] ?? null,
'remote_profile' => $target->external_profile_name,
'status' => 'active',
'validity_started_at' => $profile->validity_start === 'activation' ? now() : null,
'expires_at' => $profile->validity_start === 'activation' ? $this->expiresAt($profile) : null,
'last_synced_at' => now(),
]));
} catch (Throwable $exception) {
try {
$this->routerOs->deleteAccount($mikrotik, $profile->service_type, $username);
} catch (Throwable) {
// Compensating delete is best effort; original database error remains authoritative.
}
throw $exception;
}
}
public function setEnabled(Customer $customer, bool $enabled): CustomerNasAccount
{
$account = $this->account($customer);
$remote = $this->routerOs->setEnabled(
$account->mikrotik,
$account->service_type,
$account->username,
$enabled,
);
$account->update([
'remote_id' => $remote['.id'] ?? $account->remote_id,
'status' => $enabled ? 'active' : 'disabled',
'last_synced_at' => now(),
'last_sync_error' => null,
]);
return $account->fresh();
}
public function update(Customer $customer, array $attributes): CustomerNasAccount
{
$account = $this->account($customer);
$remote = $this->routerOs->updateAccount(
$account->mikrotik,
$account->service_type,
$account->username,
$attributes,
);
$account->update([
'username' => $attributes['username'] ?? $account->username,
'password' => $attributes['password'] ?? $account->password,
'remote_profile' => $attributes['profile'] ?? $account->remote_profile,
'remote_id' => $remote['.id'] ?? $account->remote_id,
'last_synced_at' => now(),
'last_sync_error' => null,
]);
return $account->fresh();
}
public function delete(Customer $customer): void
{
$account = $customer->nasAccount()->with('mikrotik')->first();
if (! $account) {
return;
}
$this->routerOs->deleteAccount($account->mikrotik, $account->service_type, $account->username);
$account->delete();
}
private function resources(Customer $customer, array $data): array
{
$mikrotik = NasMikrotik::whereKey($data['nas_mikrotik_id'])
->where('tenant_id', $customer->tenant_id)->firstOrFail();
$profile = NasPackageProfile::whereKey($data['package_profile_id'])
->where('tenant_id', $customer->tenant_id)->firstOrFail();
return [$mikrotik, $profile];
}
private function account(Customer $customer): CustomerNasAccount
{
$account = $customer->nasAccount()->with('mikrotik')->first();
if (! $account) {
throw ValidationException::withMessages(['nas_account' => 'Akun RouterOS customer belum tercatat.']);
}
return $account;
}
private function comment(Customer $customer): string
{
return "ManjaPro | {$customer->customer_code} | {$customer->name}";
}
private function expiresAt(NasPackageProfile $profile): ?Carbon
{
if (! $profile->validity_value || ! $profile->validity_unit) return null;
return match ($profile->validity_unit) {
'minutes' => now()->addMinutes($profile->validity_value),
'hours' => now()->addHours($profile->validity_value),
'days' => now()->addDays($profile->validity_value),
'months' => now()->addMonths($profile->validity_value),
default => null,
};
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Services\Nas;
use App\Models\NasMikrotik;
use Throwable;
class MikrotikHealthService
{
public function __construct(
protected RouterOsApiService $routerOs,
protected RadiusHealthService $radius,
) {}
public function check(NasMikrotik $mikrotik): array
{
try {
$result = $mikrotik->connection_type === 'radius'
? $this->radius->testConnection($mikrotik)
: $this->routerOs->testConnection($mikrotik);
$mikrotik->update([
'connection_status' => 'online',
'last_checked_at' => now(),
'last_connected_at' => now(),
'last_latency_ms' => $result['latency_ms'],
'consecutive_failures' => 0,
'router_identity' => $result['identity'] ?? null,
'router_version' => $result['version'] ?? null,
'health_details' => $result,
'next_health_check_at' => now()->addMinutes(config('nas.health.online_interval_minutes', 5)),
'last_connection_error' => null,
]);
return $result;
} catch (Throwable $exception) {
$message = $exception instanceof \Illuminate\Validation\ValidationException
? collect($exception->errors())->flatten()->first()
: $exception->getMessage();
$failures = $mikrotik->consecutive_failures + 1;
$mikrotik->update([
'connection_status' => 'offline',
'last_checked_at' => now(),
'consecutive_failures' => $failures,
'next_health_check_at' => now()->addMinutes(min(
config('nas.health.offline_max_interval_minutes', 30),
2 ** min($failures, 5)
)),
'health_details' => [
'connected' => false,
'host' => $mikrotik->host,
'api_port' => $mikrotik->connection_type === 'api' ? $mikrotik->api_port : null,
'auth_port' => $mikrotik->connection_type === 'radius' ? $mikrotik->radius_auth_port : null,
'accounting_port' => $mikrotik->connection_type === 'radius' ? $mikrotik->radius_accounting_port : null,
],
'last_connection_error' => $message,
]);
throw $exception;
}
}
}
+51 -4
View File
@@ -9,6 +9,7 @@ use App\Models\NasWebfigDevice;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
use App\Models\NasScriptPolicy;
class NasResourceService
{
@@ -31,7 +32,7 @@ class NasResourceService
$model = $this->model($resource);
return $model::query()
->with('tenant:id,tenant_code,tenant_name')
->with(['tenant:id,tenant_code,tenant_name', ...($resource === 'mikrotik' ? ['tenant.nasSetting'] : []), ...($resource === 'package-profile' ? ['scripts.latestVersion'] : [])])
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status']))
@@ -40,7 +41,7 @@ class NasResourceService
->when(! empty($filters['search']), function ($q) use ($filters, $resource) {
$columns = match ($resource) {
'mikrotik' => ['name', 'host', 'api_username'],
'package-profile' => ['name', 'external_profile_name'],
'package-profile' => ['name', 'profile_code'],
'olt' => ['name', 'host', 'vendor', 'model'],
'webfig' => ['name', 'url', 'device_type'],
};
@@ -59,11 +60,15 @@ class NasResourceService
{
$model = $this->model($resource);
return $model::with('tenant:id,tenant_code,tenant_name')->findOrFail($id);
return $model::with(['tenant:id,tenant_code,tenant_name', ...($resource === 'mikrotik' ? ['tenant.nasSetting'] : []), ...($resource === 'package-profile' ? ['scripts.latestVersion'] : [])])->findOrFail($id);
}
public function create(string $resource, array $data, User $actor, ?int $tenantId): Model
{
if ($resource === 'mikrotik') $data['connection_type'] = 'api';
if ($resource === 'package-profile') $data = $this->normalizePackageProfile($data);
$scripts = $resource === 'package-profile' ? ($data['scripts'] ?? []) : [];
unset($data['scripts']);
$data['tenant_id'] = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
if (! $data['tenant_id']) {
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
@@ -71,11 +76,18 @@ class NasResourceService
$data['status'] ??= 'active';
$model = $this->model($resource);
return $model::create($data)->load('tenant:id,tenant_code,tenant_name');
$created = $model::create($data);
if ($resource === 'package-profile') $this->syncProfileScripts($created, $scripts);
return $created->load('tenant:id,tenant_code,tenant_name');
}
public function update(string $resource, Model $model, array $data, User $actor): Model
{
if ($resource === 'mikrotik') $data['connection_type'] = 'api';
if ($resource === 'package-profile') $data = $this->normalizePackageProfile($data, $model);
$hasScripts = $resource === 'package-profile' && array_key_exists('scripts', $data);
$scripts = $data['scripts'] ?? [];
unset($data['scripts']);
if (! $actor->isMasterAdmin()) {
unset($data['tenant_id']);
}
@@ -85,10 +97,45 @@ class NasResourceService
}
}
$model->update($data);
if ($hasScripts) $this->syncProfileScripts($model, $scripts);
return $model->fresh()->load('tenant:id,tenant_code,tenant_name');
}
private function syncProfileScripts(Model $profile, array $scripts): void
{
$sync=[];
foreach($scripts as $row) {
$policy = NasScriptPolicy::with('versions:id,nas_script_policy_id')->findOrFail($row['policy_id']);
if ($policy->service_type !== $profile->service_type || $policy->event !== $row['event']) {
throw ValidationException::withMessages(['scripts' => "Script {$policy->name} tidak sesuai tipe layanan atau event profile."]);
}
if (($row['version_id'] ?? null) && ! $policy->versions->contains('id', $row['version_id'])) {
throw ValidationException::withMessages(['scripts' => "Versi Script {$policy->name} tidak valid."]);
}
$sync[$row['policy_id']]=['event'=>$row['event'],'nas_script_policy_version_id'=>$row['version_id'] ?? null,'enabled'=>true];
}
$profile->scripts()->sync($sync);
}
private function normalizePackageProfile(array $data, ?Model $profile = null): array
{
$type = $data['service_type'] ?? $profile?->service_type;
if ($type !== 'hotspot') {
foreach (['usage_mode','validity_start','validity_value','validity_unit','uptime_limit_seconds','data_limit_bytes','grace_period_minutes'] as $field) $data[$field] = null;
}
if ($type !== 'ppp') foreach (['local_address', 'remote_address_pool'] as $field) $data[$field] = null;
if ($type !== 'hotspot') foreach (['session_timeout', 'idle_timeout', 'shared_users'] as $field) $data[$field] = null;
if (! in_array($type, ['static_ip', 'dynamic_ip'], true)) {
foreach (['ip_pool','subnet_cidr','gateway','dns_servers','vlan_id'] as $field) $data[$field] = null;
$data['mac_lock_required'] = false;
} else {
$data['mac_lock_required'] = true;
if ($type === 'static_ip') $data['ip_pool'] = null;
}
return $data;
}
public function delete(Model $model): bool
{
return $model->delete();
@@ -0,0 +1,93 @@
<?php
namespace App\Services\Nas;
use App\Models\NasMikrotik;
use App\Models\NasPackageProfile;
use App\Models\NasPackageProfileTarget;
use Illuminate\Validation\ValidationException;
use Throwable;
class PackageProfileDeploymentService
{
public function __construct(protected RouterOsApiService $routerOs) {}
public function targets(NasPackageProfile $profile)
{
return $profile->targets()->with('mikrotik:id,tenant_id,name,connection_type,host,connection_status')->orderBy('id')->get();
}
public function saveTarget(NasPackageProfile $profile, array $data): NasPackageProfileTarget
{
$nas = NasMikrotik::with('tenant.nasSetting')->whereKey($data['nas_mikrotik_id'])->where('tenant_id', $profile->tenant_id)->firstOrFail();
$backendMode = $nas->effectiveBackendMode();
return NasPackageProfileTarget::updateOrCreate(
['nas_package_profile_id' => $profile->id, 'nas_mikrotik_id' => $nas->id],
['tenant_id' => $profile->tenant_id, 'backend_type' => in_array($backendMode, ['platform_radius','external_radius','hybrid'], true) ? 'radius' : 'routeros_api',
'external_profile_name' => $data['external_profile_name'], 'sync_status' => 'not_deployed']
);
}
public function sync(NasPackageProfileTarget $target, string $mode): NasPackageProfileTarget
{
$target->load(['packageProfile.scripts.versions', 'mikrotik']);
$desired = $this->desiredSnapshot($target->packageProfile);
try {
$remote = $target->backend_type === 'routeros_api'
? (in_array($target->packageProfile->service_type, ['static_ip', 'dynamic_ip'], true)
? ['managed_per_customer' => true, 'network' => $desired['network']]
: $this->routerOs->syncProfile($target->mikrotik, $target->packageProfile->service_type, $target->external_profile_name, $desired['routeros'], $mode))
: ['radius_attributes' => $desired['radius']];
$checksum = hash('sha256', json_encode($desired));
$target->update(['sync_status' => 'synced', 'desired_snapshot' => $desired, 'remote_snapshot' => $remote,
'remote_id' => $remote['.id'] ?? null, 'deployed_checksum' => $checksum, 'last_checked_at' => now(), 'last_synced_at' => now(), 'last_error' => null]);
} catch (Throwable $e) {
$target->update(['sync_status' => 'error', 'desired_snapshot' => $desired, 'last_checked_at' => now(), 'last_error' => $e instanceof ValidationException ? collect($e->errors())->flatten()->first() : $e->getMessage()]);
throw $e;
}
return $target->fresh('mikrotik');
}
public function assertSynced(NasPackageProfile $profile, NasMikrotik $nas): NasPackageProfileTarget
{
$target = $profile->targets()->where('nas_mikrotik_id', $nas->id)->where('sync_status', 'synced')->first();
if (! $target) throw ValidationException::withMessages(['package_profile_id' => 'Profile paket belum disinkronkan ke NAS yang dipilih.']);
return $target;
}
private function desiredSnapshot(NasPackageProfile $profile): array
{
$scripts = $profile->scripts->keyBy(fn ($policy) => $policy->pivot->event);
$script = fn (string $event) => $this->scriptTemplate($scripts->get($event), $profile);
$rate = ($profile->upload_kbps ? ($profile->upload_kbps.'k') : '0').'/'.($profile->download_kbps ? ($profile->download_kbps.'k') : '0');
$common = ['rate-limit' => $rate, 'session-timeout' => $profile->session_timeout ? $profile->session_timeout.'s' : null,
'idle-timeout' => $profile->idle_timeout ? $profile->idle_timeout.'s' : null];
$router = $profile->service_type === 'hotspot'
? [...$common, 'shared-users' => (string) $profile->shared_users, 'on-login' => $script('on_login'), 'on-logout' => $script('on_logout')]
: ($profile->service_type === 'ppp'
? [...$common, 'local-address' => $profile->local_address, 'remote-address' => $profile->remote_address_pool, 'on-up' => $script('on_up'), 'on-down' => $script('on_down')]
: []);
return ['routeros' => $router, 'radius' => ['Mikrotik-Rate-Limit' => $rate, 'Session-Timeout' => $profile->session_timeout, 'Idle-Timeout' => $profile->idle_timeout, 'Simultaneous-Use' => $profile->shared_users],
'network' => $profile->only(['mac_lock_required','ip_pool','subnet_cidr','gateway','dns_servers','vlan_id']),
'profile' => $profile->only(['profile_code','service_type','usage_mode','validity_start','validity_value','validity_unit','uptime_limit_seconds','data_limit_bytes'])];
}
private function scriptTemplate($policy, NasPackageProfile $profile): ?string
{
if (! $policy || ! $policy->pivot->enabled) return null;
$versionId = $policy->pivot->nas_script_policy_version_id;
$version = $versionId
? $policy->versions->firstWhere('id', $versionId)
: $policy->versions->sortByDesc('version')->first();
if (! $version) return null;
return strtr($version->script_template, [
'{{ username }}' => '$user',
'{{username}}' => '$user',
'{{ package_code }}' => (string) $profile->profile_code,
'{{package_code}}' => (string) $profile->profile_code,
'{{ tenant_code }}' => (string) optional($profile->tenant)->tenant_code,
'{{tenant_code}}' => (string) optional($profile->tenant)->tenant_code,
]);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Services\Nas;
use App\Models\NasMikrotik;
use Illuminate\Validation\ValidationException;
class RadiusHealthService
{
public function testConnection(NasMikrotik $radius): array
{
if (! $radius->host || ! $radius->radius_secret) {
throw ValidationException::withMessages(['radius' => 'Host dan shared secret RADIUS wajib lengkap.']);
}
$socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
if ($socket === false) {
throw ValidationException::withMessages(['radius' => 'Socket UDP untuk RADIUS tidak dapat dibuat.']);
}
$timeout = max(1, min((int) ($radius->timeout ?: 5), 10));
socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => $timeout, 'usec' => 0]);
$identifier = random_int(0, 255);
$requestAuthenticator = random_bytes(16);
$request = pack('CCn', 12, $identifier, 20).$requestAuthenticator;
$startedAt = hrtime(true);
try {
$sent = @socket_sendto(
$socket,
$request,
strlen($request),
0,
$radius->host,
(int) ($radius->radius_auth_port ?: 1812),
);
if ($sent === false) {
throw ValidationException::withMessages(['radius' => 'Status-Server RADIUS gagal dikirim.']);
}
$response = '';
$from = '';
$port = 0;
$received = @socket_recvfrom($socket, $response, 4096, 0, $from, $port);
if ($received === false || $received < 20) {
throw ValidationException::withMessages([
'radius' => 'RADIUS tidak merespons Status-Server. Pastikan Status-Server diizinkan oleh server.',
]);
}
['code' => $code, 'identifier' => $responseIdentifier, 'length' => $length] = unpack('Ccode/Cidentifier/nlength', substr($response, 0, 4));
if ($responseIdentifier !== $identifier || $length > strlen($response)) {
throw ValidationException::withMessages(['radius' => 'Respons RADIUS tidak valid.']);
}
$attributes = substr($response, 20, $length - 20);
$expected = md5(substr($response, 0, 4).$requestAuthenticator.$attributes.$radius->radius_secret, true);
if (! hash_equals($expected, substr($response, 4, 16))) {
throw ValidationException::withMessages(['radius' => 'Authenticator respons RADIUS tidak valid.']);
}
return [
'connected' => true,
'latency_ms' => (int) round((hrtime(true) - $startedAt) / 1_000_000),
'host' => $radius->host,
'auth_port' => (int) ($radius->radius_auth_port ?: 1812),
'accounting_port' => (int) ($radius->radius_accounting_port ?: 1813),
'response_code' => $code,
'probe' => 'radius_status_server',
];
} finally {
socket_close($socket);
}
}
}
+185
View File
@@ -0,0 +1,185 @@
<?php
namespace App\Services\Nas;
use App\Models\NasMikrotik;
use Illuminate\Validation\ValidationException;
use RouterOS\Client;
use RouterOS\Query;
use Throwable;
class RouterOsApiService
{
public function testConnection(NasMikrotik $mikrotik): array
{
$startedAt = hrtime(true);
$client = $this->connect($mikrotik);
$identity = $client->query('/system/identity/print')->read()[0] ?? [];
$resource = $client->query('/system/resource/print')->read()[0] ?? [];
return [
'connected' => true,
'latency_ms' => (int) round((hrtime(true) - $startedAt) / 1_000_000),
'identity' => $identity['name'] ?? null,
'version' => $resource['version'] ?? null,
'uptime' => $resource['uptime'] ?? null,
'platform' => $resource['platform'] ?? null,
'board_name' => $resource['board-name'] ?? null,
];
}
public function createAccount(NasMikrotik $mikrotik, string $serviceType, array $attributes): array
{
$client = $this->connect($mikrotik);
$this->assertUsernameAvailable($client, $serviceType, $attributes['username']);
$query = new Query($this->path($serviceType, 'add'));
foreach ($this->accountAttributes($attributes) as $key => $value) {
$query->equal($key, $value);
}
$client->query($query)->read();
return $this->findAccount($client, $serviceType, $attributes['username']);
}
public function updateAccount(NasMikrotik $mikrotik, string $serviceType, string $username, array $attributes): array
{
$client = $this->connect($mikrotik);
$account = $this->findAccount($client, $serviceType, $username);
$query = (new Query($this->path($serviceType, 'set')))->equal('.id', $account['.id']);
foreach ($this->accountAttributes($attributes) as $key => $value) {
$query->equal($key, $value);
}
$client->query($query)->read();
return $this->findAccount($client, $serviceType, $attributes['username'] ?? $username);
}
public function setEnabled(NasMikrotik $mikrotik, string $serviceType, string $username, bool $enabled): array
{
return $this->updateAccount($mikrotik, $serviceType, $username, [
'disabled' => $enabled ? 'no' : 'yes',
]);
}
public function deleteAccount(NasMikrotik $mikrotik, string $serviceType, string $username): void
{
$client = $this->connect($mikrotik);
$account = $this->findAccount($client, $serviceType, $username);
$client->query(
(new Query($this->path($serviceType, 'remove')))->equal('.id', $account['.id'])
)->read();
}
public function syncProfile(NasMikrotik $mikrotik, string $serviceType, string $name, array $attributes, string $mode = 'update'): array
{
$client = $this->connect($mikrotik);
$base = $serviceType === 'hotspot' ? '/ip/hotspot/user/profile' : '/ppp/profile';
$rows = $client->query((new Query("{$base}/print"))->where('name', $name))->read();
$existing = $rows[0] ?? null;
if ($mode === 'adopt') {
if (! $existing) throw ValidationException::withMessages(['profile' => "Profile {$name} tidak ditemukan untuk di-adopt."]);
return $existing;
}
if (! $existing) {
$query = (new Query("{$base}/add"))->equal('name', $name);
} else {
$query = (new Query("{$base}/set"))->equal('.id', $existing['.id']);
}
foreach (array_filter($attributes, fn ($value) => $value !== null) as $key => $value) $query->equal($key, $value);
$client->query($query)->read();
$rows = $client->query((new Query("{$base}/print"))->where('name', $name))->read();
return $rows[0] ?? [];
}
public function inspectAccount(NasMikrotik $mikrotik, string $serviceType, string $username): array
{
$client = $this->connect($mikrotik);
$account = $this->findAccount($client, $serviceType, $username);
$activeBase = in_array($serviceType, ['ppp','pppoe'], true) ? '/ppp/active' : '/ip/hotspot/active';
$active = $client->query((new Query("{$activeBase}/print"))->where('name', $username))->read()[0] ?? null;
return ['account' => $account, 'active' => $active];
}
public function disconnectAccount(NasMikrotik $mikrotik, string $serviceType, string $username): void
{
$client = $this->connect($mikrotik);
$activeBase = in_array($serviceType, ['ppp','pppoe'], true) ? '/ppp/active' : '/ip/hotspot/active';
$rows = $client->query((new Query("{$activeBase}/print"))->where('name', $username))->read();
foreach ($rows as $row) if (! empty($row['.id'])) $client->query((new Query("{$activeBase}/remove"))->equal('.id', $row['.id']))->read();
}
private function connect(NasMikrotik $mikrotik): Client
{
if ($mikrotik->connection_type !== 'api') {
throw ValidationException::withMessages(['mikrotik' => 'Perangkat tidak menggunakan koneksi RouterOS API.']);
}
if ($mikrotik->status !== 'active') {
throw ValidationException::withMessages(['mikrotik' => 'Perangkat Mikrotik sedang nonaktif.']);
}
if (! $mikrotik->host || ! $mikrotik->api_username || ! $mikrotik->api_password) {
throw ValidationException::withMessages(['mikrotik' => 'Host, username API, dan password API wajib lengkap.']);
}
try {
return new Client([
'host' => $mikrotik->host,
'user' => $mikrotik->api_username,
'pass' => $mikrotik->api_password,
'port' => (int) ($mikrotik->api_port ?: 8728),
'timeout' => (int) ($mikrotik->timeout ?: 10),
'socket_timeout' => (int) ($mikrotik->timeout ?: 10),
'attempts' => 1,
'delay' => 0,
'ssl' => (int) ($mikrotik->api_port ?: 8728) === 8729,
]);
} catch (Throwable $exception) {
throw ValidationException::withMessages([
'mikrotik' => 'Koneksi RouterOS API gagal: '.$exception->getMessage(),
]);
}
}
private function findAccount(Client $client, string $serviceType, string $username): array
{
$rows = $client->query(
(new Query($this->path($serviceType, 'print')))->where('name', $username)
)->read();
if (empty($rows[0]['.id'])) {
throw ValidationException::withMessages(['username' => "Akun {$username} tidak ditemukan di Mikrotik."]);
}
return $rows[0];
}
private function assertUsernameAvailable(Client $client, string $serviceType, string $username): void
{
$rows = $client->query(
(new Query($this->path($serviceType, 'print')))->where('name', $username)
)->read();
if ($rows) {
throw ValidationException::withMessages(['username' => "Username {$username} sudah ada di Mikrotik."]);
}
}
private function accountAttributes(array $attributes): array
{
return array_filter([
'name' => $attributes['username'] ?? null,
'password' => $attributes['password'] ?? null,
'profile' => $attributes['profile'] ?? null,
'comment' => $attributes['comment'] ?? null,
'disabled' => $attributes['disabled'] ?? null,
], fn ($value) => $value !== null);
}
private function path(string $serviceType, string $action): string
{
$base = match ($serviceType) {
'ppp', 'pppoe' => '/ppp/secret',
'hotspot' => '/ip/hotspot/user',
default => throw ValidationException::withMessages(['service_type' => 'Tipe layanan harus PPPoE atau Hotspot.']),
};
return "{$base}/{$action}";
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Nas;
use App\Models\NasScriptPolicy;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ScriptPolicyService
{
private const VARIABLES = ['username','customer_code','customer_name','package_code','tenant_code','valid_until'];
public function list(array $filters) { return NasScriptPolicy::with('latestVersion')->when($filters['search'] ?? null, fn($q,$s)=>$q->where('name','ILIKE',"%{$s}%")->orWhere('code','ILIKE',"%{$s}%"))->orderBy('service_type')->orderBy('event')->paginate($filters['per_page'] ?? 10); }
public function save(array $data, int $userId, ?NasScriptPolicy $policy = null): NasScriptPolicy
{
$allowedEvents = $data['service_type'] === 'ppp' ? ['on_up', 'on_down'] : ['on_login', 'on_logout'];
if (! in_array($data['event'], $allowedEvents, true)) {
throw ValidationException::withMessages(['event' => 'Event tidak sesuai dengan tipe layanan Script Policy.']);
}
preg_match_all('/\{\{\s*([a-z_]+)\s*\}\}/', $data['script_template'], $matches);
$invalid = array_diff(array_unique($matches[1]), self::VARIABLES);
if ($invalid) throw ValidationException::withMessages(['script_template' => 'Variabel tidak diizinkan: '.implode(', ', $invalid)]);
return DB::transaction(function () use ($data,$userId,$policy) {
$version = $policy ? $policy->current_version + 1 : 1;
$values = collect($data)->except(['script_template','change_notes'])->all();
$values['allowed_variables'] = self::VARIABLES; $values['current_version'] = $version; $values['created_by'] ??= $userId;
$policy ? $policy->update($values) : $policy = NasScriptPolicy::create($values);
$policy->versions()->create(['version'=>$version,'script_template'=>$data['script_template'],'checksum'=>hash('sha256',$data['script_template']),'change_notes'=>$data['change_notes'] ?? null,'created_by'=>$userId]);
return $policy->fresh(['latestVersion','versions']);
});
}
}
+21 -8
View File
@@ -4,34 +4,45 @@ namespace App\Services\Ticket;
use App\Models\Ticket;
use App\Models\TicketLog;
use App\Models\TicketType;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class TicketService
{
public function create(array $data): Ticket
public function create(array $data, User $actor, ?int $tenantId): Ticket
{
return DB::transaction(function () use ($data) {
return DB::transaction(function () use ($data, $actor, $tenantId) {
$tenantId = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
abort_unless($tenantId, 422, 'Tenant wajib dipilih.');
if (! empty($data['customer_id'])) abort_unless(\App\Models\Customer::whereKey($data['customer_id'])->where('tenant_id', $tenantId)->exists(), 422, 'Customer bukan milik tenant aktif.');
$type = TicketType::findOrFail($data['ticket_type_id']);
$ticket = Ticket::create([
'ticket_no' => $this->generateTicketNo(),
'ticket_type_id' => $data['ticket_type_id'],
'customer_id' => $data['customer_id'] ?? null,
'tenant_id' => $tenantId,
'title' => $data['title'],
'description' => $data['description'] ?? null,
'priority' => $data['priority'],
'status' => 'open',
'created_by' => 1
'status' => $type->need_approval ? 'open' : 'approved',
'sla_minutes' => $type->sla_minutes,
'created_by' => $actor->id
,'approved_by' => $type->need_approval ? null : $actor->id
,'approved_at' => $type->need_approval ? null : now()
]);
TicketLog::create([
'ticket_id' => $ticket->id,
'user_id' => 1,
'user_id' => $actor->id,
'activity' => 'Ticket Created',
'notes' => 'Ticket dibuat oleh sistem',
'notes' => 'Ticket dibuat oleh '.$actor->name,
'created_at'=> now()
]);
return $ticket;
return $ticket->load(['ticketType','customer','tenant','creator','assignments.user']);
});
}
@@ -76,4 +87,6 @@ class TicketService
STR_PAD_LEFT
);
}
}
public function generateNo(): string { return $this->generateTicketNo(); }
}
+27 -8
View File
@@ -39,7 +39,7 @@ class TicketWorkflowService
// PHOTO VALIDATION
if ($type?->require_photo) {
$photoCount = $payload['photo_count'] ?? 0;
$photoCount = $payload['photo_count'] ?? ($ticket->customer?->images()->count() ?? 0);
if ($photoCount <= 0) {
throw ValidationException::withMessages([
@@ -50,7 +50,7 @@ class TicketWorkflowService
// MATERIAL VALIDATION
if ($type?->require_material) {
$materialCount = $payload['material_count'] ?? 0;
$materialCount = $payload['material_count'] ?? \App\Models\TicketMaterial::where('ticket_id', $ticket->id)->count();
if ($materialCount <= 0) {
throw ValidationException::withMessages([
@@ -85,6 +85,8 @@ class TicketWorkflowService
): Ticket {
TicketWorkflowValidator::allow($ticket, ['approved']);
$validCount=\App\Models\UserTenant::where('tenant_id',$ticket->tenant_id)->whereIn('user_id',$technicians)->distinct('user_id')->count('user_id');
if($validCount!==count(array_unique($technicians)))throw ValidationException::withMessages(['technicians'=>'Semua teknisi harus terdaftar pada tenant ticket.']);
DB::transaction(function () use (
$ticket,
@@ -219,8 +221,10 @@ class TicketWorkflowService
public function start(Ticket $ticket, array $location, int $userId): Ticket
{
TicketWorkflowValidator::allow($ticket, ['assigned']);
$this->ensureAssignedActor($ticket,$userId);
DB::transaction(function () use ($ticket, $location, $userId) {
$this->attachFiles($ticket,$location['stored_file_ids'],'start',$userId,$location['notes']??null);
$ticket->update([
'status' => 'progress'
@@ -237,17 +241,17 @@ class TicketWorkflowService
return $ticket->fresh();
}
public function pending(Ticket $ticket, string $reason, int $userId): Ticket
public function pending(Ticket $ticket, string $reason, int $userId, array $fileIds=[]): Ticket
{
$ticket->update(['status' => 'pending']);
$this->log($ticket->id, $userId, 'Pending', $reason);
TicketWorkflowValidator::allow($ticket, ['progress']);$this->ensureAssignedActor($ticket,$userId);
DB::transaction(function()use($ticket,$reason,$userId,$fileIds){$this->attachFiles($ticket,$fileIds,'pending',$userId,$reason);$ticket->update(['status'=>'pending']);$this->log($ticket->id,$userId,'Pending',$reason);});
return $ticket->fresh();
}
public function resume(Ticket $ticket, int $userId): Ticket
{
TicketWorkflowValidator::allow($ticket, ['pending']);$this->ensureAssignedActor($ticket,$userId);
$ticket->update(['status' => 'progress']);
$this->log($ticket->id, $userId, 'Resume', 'Dilanjutkan');
@@ -269,12 +273,15 @@ class TicketWorkflowService
): Ticket {
TicketWorkflowValidator::allow($ticket, ['progress']);
$this->ensureAssignedActor($ticket,$userId);
if ($ticket->source === 'new_installation' && $ticket->installation_step !== 'completed') throw ValidationException::withMessages(['installation_step'=>'Seluruh proses pemasangan dan aktivasi harus diselesaikan terlebih dahulu.']);
// 🔥 VALIDASI MATERIAL + PHOTO + CUSTOMER
$this->ensureRequiredAssets($ticket, $payload);
$this->ensureCustomer($ticket);
DB::transaction(function () use ($ticket, $notes, $userId) {
DB::transaction(function () use ($ticket, $notes, $userId, $payload) {
$this->attachFiles($ticket,$payload['stored_file_ids']??[],'resolve',$userId,$notes);
$ticket->update([
'status' => 'resolved',
@@ -290,6 +297,7 @@ class TicketWorkflowService
public function close(Ticket $ticket, ?string $notes, int $userId): Ticket
{
TicketWorkflowValidator::allow($ticket, ['resolved']);
if (! $ticket->verified_at) throw ValidationException::withMessages(['verification'=>'Ticket harus diverifikasi sebelum ditutup.']);
$ticket->update([
'status' => 'closed',
@@ -338,4 +346,15 @@ class TicketWorkflowService
'created_at'=> now()
]);
}
}
private function ensureAssignedActor(Ticket $ticket,int $userId):void
{
$user=\App\Models\User::findOrFail($userId);
if($user->isMasterAdmin()||$user->isTenantOwner($ticket->tenant_id))return;
abort_unless(TicketAssignment::where('ticket_id',$ticket->id)->where('user_id',$userId)->whereIn('status',['assigned','accepted'])->exists(),403,'Pekerjaan hanya dapat diproses teknisi yang ditugaskan.');
}
private function attachFiles(Ticket $ticket,array $ids,string $stage,int $userId,?string $notes=null):void
{
foreach(array_unique($ids) as $id){$file=\App\Models\StoredFile::findOrFail($id);abort_unless((int)$file->tenant_id===(int)$ticket->tenant_id,422,'File bukan milik tenant ticket.');\App\Models\TicketAttachment::firstOrCreate(['ticket_id'=>$ticket->id,'stored_file_id'=>$id,'stage'=>$stage],['uploaded_by'=>$userId,'notes'=>$notes]);}
}
}
@@ -3,7 +3,7 @@
namespace App\Services\Ticket;
use App\Models\Ticket;
use Exception;
use Illuminate\Validation\ValidationException;
class TicketWorkflowValidator
{
@@ -14,9 +14,7 @@ class TicketWorkflowValidator
if (! in_array($ticket->status, $allowedStatuses)) {
throw new Exception(
"Ticket status '{$ticket->status}' tidak valid untuk proses ini"
);
throw ValidationException::withMessages(['status' => "Ticket berstatus '{$ticket->status}' tidak dapat menjalankan proses ini."]);
}
}
}
}