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
+4
View File
@@ -5,6 +5,10 @@ APP_DEBUG=true
APP_URL=http://localhost
APP_TIMEZONE=Asia/Jakarta
NAS_HEALTH_QUEUE=nas-health
NAS_HEALTH_ONLINE_INTERVAL=5
NAS_HEALTH_OFFLINE_MAX_INTERVAL=30
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Models\NasMikrotik;
use App\Jobs\CheckMikrotikConnection;
use Illuminate\Console\Command;
class CheckMikrotikConnections extends Command
{
protected $signature = 'nas:check-mikrotiks {--id=* : ID Mikrotik tertentu}';
protected $description = 'Mengantrekan pemeriksaan koneksi NAS yang sudah jatuh tempo';
public function handle(): int
{
$queued = 0;
NasMikrotik::query()
->where('status', 'active')
->where(fn ($query) => $query->whereNull('next_health_check_at')->orWhere('next_health_check_at', '<=', now()))
->when($this->option('id'), fn ($query) => $query->whereIn('id', $this->option('id')))
->select(['id'])
->chunkById(100, function ($devices) use (&$queued) {
foreach ($devices as $index => $device) {
CheckMikrotikConnection::dispatch($device->id)->delay(now()->addSeconds($index % 60));
$queued++;
}
});
$this->info("{$queued} pemeriksaan NAS masuk antrean nas-health.");
return self::SUCCESS;
}
}
@@ -10,6 +10,8 @@ use App\Http\Requests\Customer\UpdateCustomerRequest;
use App\Http\Resources\Customer\CustomerResource;
use App\Services\Customer\CustomerService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
class CustomerController extends ApiController
{
@@ -32,6 +34,60 @@ class CustomerController extends ApiController
);
}
public function odpPorts(Request $request, int $node)
{
$data=$request->validate(['customer_id'=>['nullable','integer','exists:customers,id']]);
return $this->success($this->service->availableOdpPorts($node,$request->user(),$this->tenantId($request),$data['customer_id'] ?? null),'Port ODP tersedia berhasil diambil');
}
public function ocrKtp(Request $request)
{
$data = $request->validate([
'image' => ['required', 'image', 'mimes:jpeg,jpg,png,webp', 'max:5120'],
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
]);
$tenantId = $request->user()->isMasterAdmin()
? ($data['tenant_id'] ?? $request->attributes->get('tenant_id'))
: $request->attributes->get('tenant_id');
abort_unless($tenantId, 422, 'Tenant wajib dipilih sebelum menjalankan OCR.');
if (! $request->user()->isMasterAdmin()) {
abort_unless($request->user()->userTenants()->where('tenant_id', $tenantId)->exists(), 403);
}
$setting = \App\Models\TenantInstallationSetting::where('tenant_id', $tenantId)->first();
abort_unless($setting?->ktp_ocr_enabled, 422, 'OCR KTP belum diaktifkan untuk tenant ini.');
abort_unless($setting->ocr_provider && $setting->ocr_api_key, 422, 'Provider atau API key OCR belum dikonfigurasi.');
$file = $data['image'];
$mime = $file->getMimeType();
$base64 = base64_encode(file_get_contents($file->getRealPath()));
$prompt = 'Baca KTP Indonesia ini. Kembalikan hanya JSON valid tanpa markdown dengan field: nik, nama, alamat, rt_rw, kelurahan_desa, kecamatan, kabupaten_kota, provinsi. Gunakan null bila tidak terbaca.';
if ($setting->ocr_provider === 'openai') {
$response = Http::withToken($setting->ocr_api_key)->timeout(60)->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4.1-mini',
'response_format' => ['type' => 'json_object'],
'messages' => [['role' => 'user', 'content' => [
['type' => 'text', 'text' => $prompt],
['type' => 'image_url', 'image_url' => ['url' => "data:{$mime};base64,{$base64}"]],
]]],
]);
$content = $response->successful() ? $response->json('choices.0.message.content') : null;
} else {
$response = Http::timeout(60)->post('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key='.urlencode($setting->ocr_api_key), [
'contents' => [['parts' => [['text' => $prompt], ['inline_data' => ['mime_type' => $mime, 'data' => $base64]]]]],
'generationConfig' => ['responseMimeType' => 'application/json'],
]);
$content = $response->successful() ? $response->json('candidates.0.content.parts.0.text') : null;
}
if (! $response->successful() || ! $content) {
throw ValidationException::withMessages(['image' => 'Provider AI gagal membaca KTP. Periksa API key, kuota, dan kualitas gambar.']);
}
$result = json_decode(trim(str_replace(['```json', '```'], '', $content)), true);
if (! is_array($result)) throw ValidationException::withMessages(['image' => 'Respons OCR tidak dapat diproses.']);
return $this->success($result, 'KTP berhasil dibaca');
}
public function store(StoreCustomerRequest $request)
{
return $this->created(
@@ -59,12 +115,41 @@ class CustomerController extends ApiController
);
}
public function prepareActivation(UpdateCustomerRequest $request, int $id)
{
$customer = $this->service->find($id);
$this->authorizeTenant($request, $customer->tenant_id);
$mode = \App\Models\TenantInstallationSetting::firstOrCreate(
['tenant_id' => $customer->tenant_id],
['activation_mode' => 'list_order']
)->activation_mode;
abort_unless($mode === 'list_order', 422, 'Tenant mengatur persiapan aktivasi melalui Ticket.');
$customer = $this->service->update($customer, $request->validated(), $request->user());
$customer = $this->service->ensureActivationCode($customer);
return $this->updated(
new CustomerResource($customer),
'Persiapan aktivasi berhasil disimpan'
);
}
public function updateRegistration(UpdateCustomerRequest $request, int $id)
{
$customer = $this->service->find($id);
$this->authorizeTenant($request, $customer->tenant_id);
return $this->updated(
new CustomerResource($this->service->update($customer, $request->validated(), $request->user())),
'Data registrasi berhasil disimpan'
);
}
public function activate(ActivateCustomerRequest $request, int $id)
{
$customer = $this->service->find($id);
$this->authorizeTenant($request, $customer->tenant_id);
return $this->updated(new CustomerResource($this->service->activate($customer, $request->validated())), 'Customer berhasil diaktifkan');
return $this->updated(new CustomerResource($this->service->activate($customer, $request->validated(), $request->user())), 'Customer berhasil diaktifkan');
}
public function deactivate(Request $request, int $id)
@@ -84,6 +169,16 @@ class CustomerController extends ApiController
return $this->deleted('Customer dipindahkan ke Sampah');
}
public function cancelOrder(Request $request, int $id)
{
$customer = $this->service->find($id);
$this->authorizeTenant($request, $customer->tenant_id);
abort_unless($customer->status === 'order', 422, 'Hanya customer dalam List Order yang dapat dihapus melalui aksi ini.');
$this->service->trash($customer);
return $this->deleted('List Order dipindahkan ke Sampah');
}
public function restore(Request $request, int $id)
{
$customer = $this->service->find($id, true);
+8 -3
View File
@@ -22,7 +22,9 @@ class MenuController extends ApiController
if ($user->isMasterAdmin() || $user->is_platform_staff) {
$menus = MenuList::query()
->where('is_active', true)
->when($user->is_platform_staff && ! $user->isMasterAdmin(), fn ($q) => $q->whereIn('slug', ['role-applications', 'role-applications-tenants']))
->when($user->is_platform_staff && ! $user->isMasterAdmin(), fn ($q) => $q->whereIn('slug', [
'role-applications', 'role-applications-tenants', 'nas', 'nas-script-policies',
]))
->orderBy('sort_order')
->orderBy('id')
->get()
@@ -42,6 +44,7 @@ class MenuController extends ApiController
'can_update' => true,
'can_delete' => true,
'can_approve' => true,
'can_activate' => true,
'can_export' => true,
],
];
@@ -80,13 +83,14 @@ class MenuController extends ApiController
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')
)
->orderBy('m.sort_order')
->orderBy('m.id')
->get();
$menus = $menuRows->map(function ($row) {
$menus = $menuRows->map(function ($row) use ($tenantRole) {
return [
'id' => (int) $row->id,
'parent_id' => $row->parent_id ? (int) $row->parent_id : null,
@@ -102,6 +106,7 @@ class MenuController extends ApiController
'can_update' => (bool) $row->can_update,
'can_delete' => (bool) $row->can_delete,
'can_approve' => (bool) $row->can_approve,
'can_activate' => $tenantRole === 'tenant_owner' || (bool) $row->can_activate,
'can_export' => (bool) $row->can_export,
],
];
@@ -115,7 +120,7 @@ class MenuController extends ApiController
$tree = $this->removeRestrictedMenus($tree, $restrictedSlugs);
}
if (! $user->isMasterAdmin() && ! $user->is_platform_staff) {
$tree = $this->removeRestrictedMenus($tree, ['role-applications-tenants']);
$tree = $this->removeRestrictedMenus($tree, ['role-applications-tenants', 'nas-script-policies', 'nas-radius-infrastructure']);
}
if (! $user->isTenantOwner($tenantId)) {
$tree = $this->removeRestrictedMenus($tree, ['role-applications-staff']);
@@ -8,11 +8,15 @@ use App\Http\Requests\Nas\StoreNasResourceRequest;
use App\Http\Requests\Nas\UpdateNasResourceRequest;
use App\Http\Resources\Nas\NasResource;
use App\Services\Nas\NasResourceService;
use App\Services\Nas\MikrotikHealthService;
use Illuminate\Http\Request;
class NasResourceController extends ApiController
{
public function __construct(protected NasResourceService $service) {}
public function __construct(
protected NasResourceService $service,
protected MikrotikHealthService $mikrotikHealth,
) {}
public function index(IndexNasResourceRequest $request)
{
@@ -55,6 +59,18 @@ class NasResourceController extends ApiController
return $this->deleted('Data NAS berhasil dihapus');
}
public function testConnection(Request $request, int $id)
{
abort_unless($this->resource($request) === 'mikrotik', 404);
$mikrotik = $this->service->find('mikrotik', $id);
$this->authorizeTenant($request, $mikrotik->tenant_id);
return $this->success(
$this->mikrotikHealth->check($mikrotik),
'Koneksi RouterOS API berhasil'
);
}
private function resource(Request $request): string
{
return $request->route()->defaults['nas_resource'];
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\Nas;
use App\Http\Controllers\ApiController;
use App\Models\NasPackageProfile;
use App\Models\NasPackageProfileTarget;
use App\Services\Nas\PackageProfileDeploymentService;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use App\Models\NasScriptPolicy;
class PackageProfileDeploymentController extends ApiController
{
public function __construct(protected PackageProfileDeploymentService $service) {}
public function index(Request $request, NasPackageProfile $profile) { $this->authorizeProfile($request,$profile); return $this->success($this->service->targets($profile),'Target deployment berhasil diambil'); }
public function store(Request $request, NasPackageProfile $profile) { $this->authorizeProfile($request,$profile); $data=$request->validate(['nas_mikrotik_id'=>['required','integer','exists:nas_mikrotiks,id'],'external_profile_name'=>['required','string','max:255']]); return $this->created($this->service->saveTarget($profile,$data),'Target deployment berhasil disimpan'); }
public function sync(Request $request, NasPackageProfileTarget $target) { $this->authorizeProfile($request,$target->packageProfile); $data=$request->validate(['mode'=>['required',Rule::in(['create','update','adopt'])]]); return $this->updated($this->service->sync($target,$data['mode']),'Profile berhasil disinkronkan'); }
public function scriptOptions() { return $this->success(NasScriptPolicy::where('enabled',true)->with('latestVersion:id,nas_script_policy_id,version')->orderBy('service_type')->orderBy('event')->get(['id','code','name','service_type','event','current_version']),'Pilihan Script Policy berhasil diambil'); }
private function authorizeProfile(Request $request, NasPackageProfile $profile): void { abort_unless($request->user()->isMasterAdmin() || (int)$profile->tenant_id === (int)$request->attributes->get('tenant_id'),403); }
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Nas;
use App\Http\Controllers\ApiController;
use App\Models\RadiusCluster;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class RadiusClusterController extends ApiController
{
public function index(Request $request)
{
$this->authorizeMaster($request);
$data = $request->validate(['search'=>['nullable','string','max:255'],'status'=>['nullable',Rule::in(['planned','active','maintenance','inactive'])],'per_page'=>['nullable','integer','between:1,100']]);
$rows = RadiusCluster::query()->when($data['search'] ?? null, fn ($q,$search) => $q->where(fn ($x) => $x->where('name','ILIKE',"%{$search}%")->orWhere('code','ILIKE',"%{$search}%")->orWhere('region','ILIKE',"%{$search}%")))->when($data['status'] ?? null, fn ($q,$status) => $q->where('status',$status))->orderBy('name')->paginate($data['per_page'] ?? 10);
$rows->through(fn ($cluster) => $this->present($cluster));
return $this->success($rows, 'Cluster RADIUS berhasil diambil');
}
public function store(Request $request) { $this->authorizeMaster($request); return $this->created($this->present(RadiusCluster::create($this->validated($request))), 'Cluster RADIUS berhasil dibuat'); }
public function show(Request $request, RadiusCluster $radiusCluster) { $this->authorizeMaster($request); return $this->success($this->present($radiusCluster), 'Detail cluster RADIUS berhasil diambil'); }
public function update(Request $request, RadiusCluster $radiusCluster)
{
$this->authorizeMaster($request); $data=$this->validated($request,$radiusCluster);
if (blank($data['provisioning_api_token'] ?? null)) unset($data['provisioning_api_token']);
$radiusCluster->update($data); return $this->updated($this->present($radiusCluster->fresh()), 'Cluster RADIUS berhasil diperbarui');
}
public function destroy(Request $request, RadiusCluster $radiusCluster) { $this->authorizeMaster($request); $radiusCluster->delete(); return $this->deleted('Cluster RADIUS berhasil dihapus'); }
private function validated(Request $request, ?RadiusCluster $cluster=null): array
{
return $request->validate([
'code'=>['required','alpha_dash','max:80',Rule::unique('radius_clusters','code')->ignore($cluster)], 'name'=>['required','string','max:255'], 'region'=>['nullable','string','max:80'],
'auth_endpoints'=>['required','array','min:1','max:4'], 'auth_endpoints.*.host'=>['required','string','max:255'], 'auth_endpoints.*.port'=>['required','integer','between:1,65535'], 'auth_endpoints.*.priority'=>['required','integer','between:1,100'],
'accounting_endpoints'=>['required','array','min:1','max:4'], 'accounting_endpoints.*.host'=>['required','string','max:255'], 'accounting_endpoints.*.port'=>['required','integer','between:1,65535'], 'accounting_endpoints.*.priority'=>['required','integer','between:1,100'],
'coa_endpoints'=>['nullable','array','max:4'], 'coa_endpoints.*.host'=>['required','string','max:255'], 'coa_endpoints.*.port'=>['required','integer','between:1,65535'], 'coa_endpoints.*.priority'=>['required','integer','between:1,100'],
'provisioning_api_url'=>['nullable','url:http,https','max:2000'], 'provisioning_api_token'=>['nullable','string','max:4000'], 'healthcheck_url'=>['nullable','url:http,https','max:2000'],
'request_timeout'=>['required','integer','between:1,60'], 'retry_attempts'=>['required','integer','between:0,10'], 'status'=>['required',Rule::in(['planned','active','maintenance','inactive'])], 'notes'=>['nullable','string','max:5000'],
]);
}
private function present(RadiusCluster $cluster): array { $data=$cluster->attributesToArray(); unset($data['provisioning_api_token']); return [...$data,'has_provisioning_api_token'=>filled($cluster->getRawOriginal('provisioning_api_token'))]; }
private function authorizeMaster(Request $request): void { abort_unless($request->user()->isMasterAdmin(),403,'Infrastruktur RADIUS hanya dapat dikelola Master Admin.'); }
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Nas;
use App\Http\Controllers\ApiController;
use App\Models\NasScriptPolicy;
use App\Services\Nas\ScriptPolicyService;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class ScriptPolicyController extends ApiController
{
public function __construct(protected ScriptPolicyService $service) {}
public function index(Request $request) { $this->authorizePlatform($request); $data=$request->validate(['search'=>['nullable','string','max:255'],'per_page'=>['nullable','integer','between:1,100']]); return $this->success($this->service->list($data),'Script Policy berhasil diambil'); }
public function store(Request $request) { $this->authorizePlatform($request); return $this->created($this->service->save($this->validated($request),$request->user()->id),'Script Policy berhasil dibuat'); }
public function update(Request $request, NasScriptPolicy $policy) { $this->authorizePlatform($request); return $this->updated($this->service->save($this->validated($request,$policy),$request->user()->id,$policy),'Versi Script Policy berhasil dibuat'); }
public function show(Request $request, NasScriptPolicy $policy) { $this->authorizePlatform($request); return $this->success($policy->load(['latestVersion','versions'=>fn($q)=>$q->latest('version')]),'Detail Script Policy berhasil diambil'); }
private function validated(Request $request, ?NasScriptPolicy $policy=null): array { return $request->validate(['code'=>['required','alpha_dash','max:100',Rule::unique('nas_script_policies','code')->ignore($policy)],'name'=>['required','string','max:255'],'service_type'=>['required',Rule::in(['ppp','hotspot'])],'event'=>['required',Rule::in(['on_up','on_down','on_login','on_logout'])],'script_template'=>['required','string','max:65535'],'change_notes'=>['nullable','string','max:1000'],'enabled'=>['nullable','boolean']]); }
private function authorizePlatform(Request $request): void { abort_unless($request->user()->isMasterAdmin() || $request->user()->is_platform_staff,403,'Script Policy hanya dapat dikelola staff platform.'); }
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Nas;
use App\Http\Controllers\ApiController;
use App\Models\Tenant;
use App\Models\TenantNasSetting;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class TenantNasSettingController extends ApiController
{
public function show(Request $request)
{
$tenant = $this->tenant($request);
$setting = TenantNasSetting::firstOrCreate(['tenant_id' => $tenant->id], ['backend_mode' => 'routeros_api']);
return $this->success($this->present($setting->load('radiusCluster:id,code,name,region,status')), 'Pengaturan backend NAS berhasil diambil');
}
public function update(Request $request)
{
$tenant = $this->tenant($request);
$setting = TenantNasSetting::firstOrCreate(['tenant_id' => $tenant->id], ['backend_mode' => 'routeros_api']);
$data = $request->validate([
'tenant_id' => ['nullable','integer','exists:tenants,id'],
'backend_mode' => ['required', Rule::in(['routeros_api','platform_radius','external_radius'])],
'radius_cluster_id' => ['nullable','integer','exists:radius_clusters,id'],
'external_radius_host' => ['nullable','string','max:255'],
'external_auth_port' => ['nullable','integer','between:1,65535'],
'external_accounting_port' => ['nullable','integer','between:1,65535'],
'external_coa_port' => ['nullable','integer','between:1,65535'],
'external_radius_secret' => ['nullable','string','max:1000'],
'external_provisioning_api_url' => ['nullable','url:http,https','max:2000'],
'external_provisioning_api_token' => ['nullable','string','max:2000'],
'accounting_enabled' => ['nullable','boolean'],
'coa_enabled' => ['nullable','boolean'],
]);
$usesExternal = $data['backend_mode'] === 'external_radius';
if ($usesExternal && empty($data['external_radius_host'])) throw ValidationException::withMessages(['external_radius_host'=>'Host RADIUS eksternal wajib diisi.']);
$data['hybrid_radius_source'] = null;
foreach (['external_radius_secret','external_provisioning_api_token'] as $secret) if (blank($data[$secret] ?? null)) unset($data[$secret]);
unset($data['tenant_id']);
$setting->update($data);
return $this->updated($this->present($setting->fresh()->load('radiusCluster:id,code,name,region,status')), 'Pengaturan backend NAS berhasil disimpan');
}
private function tenant(Request $request): Tenant
{
$id = $request->user()->isMasterAdmin() ? ($request->integer('tenant_id') ?: $request->attributes->get('tenant_id')) : $request->attributes->get('tenant_id');
abort_unless($id, 422, 'Tenant wajib dipilih.');
return Tenant::findOrFail($id);
}
private function present(TenantNasSetting $setting): array
{
return [...$setting->attributesToArray(), 'has_external_radius_secret'=>filled($setting->getRawOriginal('external_radius_secret')), 'has_external_provisioning_api_token'=>filled($setting->getRawOriginal('external_provisioning_api_token')), 'radius_cluster'=>$setting->radiusCluster];
}
}
@@ -10,6 +10,8 @@ use App\Http\Resources\Tenant\TenantResource;
use App\Models\Tenant;
use App\Services\Tenant\TenantService;
use Illuminate\Http\Request;
use App\Models\TenantInstallationSetting;
use Illuminate\Validation\Rule;
class TenantController extends ApiController
{
@@ -90,4 +92,44 @@ class TenantController extends ApiController
403
);
}
public function installationSetting(Request $request, Tenant $tenant)
{
$this->authorizeTenantAccess($request, $tenant);
$setting = TenantInstallationSetting::firstOrCreate(['tenant_id' => $tenant->id], ['activation_mode' => 'list_order']);
return $this->success([
...$setting->toArray(),
'tenant' => new TenantResource($tenant),
'ocr_api_key_configured' => filled($setting->ocr_api_key),
'can_manage_ocr' => $request->user()->isMasterAdmin(),
]);
}
public function updateInstallationSetting(Request $request, Tenant $tenant)
{
$this->authorizeTenantAccess($request, $tenant);
$rules = ['activation_mode' => ['required', Rule::in(['list_order', 'ticket'])]];
if ($request->user()->isMasterAdmin()) {
$rules += [
'ktp_ocr_enabled' => ['required', 'boolean'],
'ocr_provider' => ['nullable', 'required_if:ktp_ocr_enabled,true', Rule::in(['gemini', 'openai'])],
'ocr_api_key' => ['nullable', 'string', 'max:4096'],
'clear_ocr_api_key' => ['nullable', 'boolean'],
];
}
$data = $request->validate($rules);
$setting = TenantInstallationSetting::firstOrCreate(['tenant_id' => $tenant->id], ['activation_mode' => 'list_order']);
if ($request->user()->isMasterAdmin()) {
if (! empty($data['clear_ocr_api_key'])) $data['ocr_api_key'] = null;
elseif (blank($data['ocr_api_key'] ?? null)) unset($data['ocr_api_key']);
unset($data['clear_ocr_api_key']);
}
$setting->update($data);
return $this->updated([
...$setting->fresh()->toArray(),
'ocr_api_key_configured' => filled($setting->fresh()->ocr_api_key),
'can_manage_ocr' => $request->user()->isMasterAdmin(),
], 'Setting tenant berhasil disimpan');
}
}
@@ -9,6 +9,11 @@ use App\Http\Requests\Ticket\UpdateTicketRequest;
use App\Http\Resources\Ticket\TicketResource;
use App\Models\Ticket;
use App\Services\Ticket\TicketService;
use App\Models\Customer;
use App\Models\TicketType;
use App\Models\User;
use App\Models\TicketMaterial;
use Illuminate\Http\Request;
class TicketController extends ApiController
{
@@ -19,7 +24,13 @@ class TicketController extends ApiController
public function index(IndexTicketRequest $request)
{
$filters = $request->validated();
$tickets = Ticket::query()
$tenantId = $request->attributes->get('tenant_id');
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortDirection = $filters['sort_direction'] ?? 'desc';
$tickets = Ticket::query()->with(['ticketType:id,code,name,need_approval,require_photo,require_material','customer:id,customer_code,name','tenant:id,tenant_name','creator:id,name','assignments.user:id,name'])
->when(! $request->user()->isMasterAdmin(), fn ($query) => $query->where('tenant_id', $tenantId))
->when($request->user()->isMasterAdmin() && $tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
->when(! empty($filters['assigned_to_me']), fn ($query) => $query->whereHas('assignments', fn ($assignment) => $assignment->where('user_id', $request->user()->id)))
->when(
! empty($filters['search']),
fn ($query) => $query->where(function ($searchQuery) use ($filters) {
@@ -44,7 +55,7 @@ class TicketController extends ApiController
! empty($filters['customer_id']),
fn ($query) => $query->where('customer_id', $filters['customer_id'])
)
->latest()
->orderBy($sortBy, $sortDirection)
->paginate($filters['per_page'] ?? 10);
return $this->successPaginated(
@@ -57,7 +68,7 @@ class TicketController extends ApiController
public function store(StoreTicketRequest $request)
{
$ticket = $this->service->create(
$request->validated()
$request->validated(), $request->user(), $request->attributes->get('tenant_id')
);
return $this->created(
@@ -66,10 +77,11 @@ class TicketController extends ApiController
);
}
public function show(Ticket $ticket)
public function show(Request $request, Ticket $ticket)
{
$this->authorizeTenant($request, $ticket);
return $this->success(
new TicketResource($ticket)
new TicketResource($ticket->load(['ticketType','customer','tenant','creator','assignments.user','logs','materials','attachments']))
);
}
@@ -77,6 +89,7 @@ class TicketController extends ApiController
UpdateTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTenant($request, $ticket);
$ticket = $this->service->update(
$ticket,
$request->validated()
@@ -88,12 +101,72 @@ class TicketController extends ApiController
);
}
public function destroy(Ticket $ticket)
public function destroy(Request $request, Ticket $ticket)
{
$this->authorizeTenant($request, $ticket);
$this->service->delete($ticket);
return $this->deleted(
'Ticket berhasil dibatalkan'
);
}
public function options(Request $request)
{
$tenantId = $request->attributes->get('tenant_id');
return $this->success([
'ticket_types' => TicketType::orderBy('name')->get(['id','code','name','need_approval','require_photo','require_material']),
'customers' => Customer::when(! $request->user()->isMasterAdmin() || $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))->orderBy('name')->limit(500)->get(['id','customer_code','name','tenant_id']),
'technicians' => User::whereHas('userTenants', fn ($q) => $q->where('tenant_id', $tenantId))->orderBy('name')->get(['id','name','email']),
'installation_setting' => $tenantId
? \App\Models\TenantInstallationSetting::firstOrCreate(['tenant_id' => $tenantId], ['activation_mode' => 'list_order'])
: null,
'package_profiles'=>\App\Models\NasPackageProfile::where('tenant_id',$tenantId)->where('status','active')->orderBy('name')->get(['id','name']),
'nas_mikrotiks'=>\App\Models\NasMikrotik::where('tenant_id',$tenantId)->where('status','active')->orderBy('name')->get(['id','name']),
'billing_profiles'=>\App\Models\BillingProfile::where('tenant_id',$tenantId)->where('status','active')->orderBy('name')->get(['id','name']),
'topology_odps'=>\App\Models\TopologyNode::where('tenant_id',$tenantId)->whereHas('deviceType',fn($q)=>$q->whereRaw('UPPER(code) = ?',['ODP']))->orderBy('name')->get(['id','code','name']),
], 'Pilihan ticket berhasil diambil');
}
public function storeMaterial(Request $request, Ticket $ticket)
{
$this->authorizeTenant($request,$ticket);
$data=$request->validate(['technician_material_id'=>['nullable','required_without:technician_consumable_id','integer','exists:technician_materials,id'],'technician_consumable_id'=>['nullable','required_without:technician_material_id','integer','exists:technician_consumables,id'],'qty'=>['required','numeric','gt:0'],'notes'=>['nullable','string','max:1000']]);
abort_if(!empty($data['technician_material_id'])&&!empty($data['technician_consumable_id']),422,'Pilih satu material saja.');
$material=\Illuminate\Support\Facades\DB::transaction(function()use($data,$ticket,$request){
if(!empty($data['technician_material_id'])){$source=\App\Models\TechnicianMaterial::whereKey($data['technician_material_id'])->where('user_id',$request->user()->id)->where('status','assigned')->lockForUpdate()->firstOrFail();$qty=1;$source->update(['status'=>'used']);$name=$source->material_name;}
else{$source=\App\Models\TechnicianConsumable::whereKey($data['technician_consumable_id'])->where('user_id',$request->user()->id)->where('status','assigned')->lockForUpdate()->firstOrFail();$qty=(float)$data['qty'];abort_if((float)$source->qty_remaining<$qty,422,'Jumlah material yang dibawa tidak mencukupi.');$source->update(['qty_used'=>(float)$source->qty_used+$qty,'qty_remaining'=>(float)$source->qty_remaining-$qty]);$name=$source->material_name;}
\App\Models\MaterialMovement::create(['barcode_id'=>$source->barcode_id,'user_id'=>$request->user()->id,'from_user_id'=>$request->user()->id,'movement_type'=>'use','ticket_id'=>$ticket->id,'qty'=>$qty,'description'=>$data['notes']??null,'created_by'=>$request->user()->id,'created_at'=>now()]);
$warehouseBarcode=\App\Models\ItemBarcode::where('barcode',$source->barcode_id)->first();
if($warehouseBarcode)\App\Models\ItemMovement::create(['tenant_id'=>$ticket->tenant_id,'warehouse_item_id'=>$warehouseBarcode->warehouse_item_id,'item_barcode_id'=>$warehouseBarcode->id,'movement_type'=>'used_on_ticket','qty'=>$qty,'reference_type'=>'ticket','reference_id'=>$ticket->id,'from_user_id'=>$request->user()->id,'notes'=>$data['notes']??null,'created_by'=>$request->user()->id]);
return $ticket->materials()->create([...$data,'material_name'=>$name,'qty'=>$qty,'used_by'=>$request->user()->id,'created_at'=>now()]);
});
\App\Models\TicketLog::create(['ticket_id'=>$ticket->id,'user_id'=>$request->user()->id,'activity'=>'Material Digunakan','notes'=>$material->material_name.' x '.$material->qty,'created_at'=>now()]);
return $this->created($material->load('user:id,name'),'Material ticket berhasil ditambahkan');
}
public function materialOptions(Request $request, Ticket $ticket)
{
$this->authorizeTenant($request,$ticket);$userId=$request->user()->id;
return $this->success(['serialized'=>\App\Models\TechnicianMaterial::where('user_id',$userId)->where('status','assigned')->get(['id','barcode_id','material_name','serial_number']),'consumables'=>\App\Models\TechnicianConsumable::where('user_id',$userId)->where('status','assigned')->where('qty_remaining','>',0)->get(['id','barcode_id','material_name','unit','qty_remaining'])],'Material yang dibawa berhasil diambil');
}
public function installationPorts(Request $request,Ticket $ticket,int $node,\App\Services\Customer\CustomerService $customers){$this->authorizeTenant($request,$ticket);return $this->success($customers->availableOdpPorts($node,$request->user(),$ticket->tenant_id,$ticket->customer_id),'Port ODP berhasil diambil');}
public function destroyMaterial(Request $request, Ticket $ticket, TicketMaterial $material)
{
$this->authorizeTenant($request,$ticket);abort_unless((int)$material->ticket_id===(int)$ticket->id,404);
abort_if(in_array($ticket->status,['resolved','closed'],true),422,'Material ticket yang sudah selesai tidak dapat diubah.');
\Illuminate\Support\Facades\DB::transaction(function()use($request,$ticket,$material){
if($material->technician_material_id){$source=\App\Models\TechnicianMaterial::lockForUpdate()->find($material->technician_material_id);if($source)$source->update(['status'=>'assigned']);}
else{$source=\App\Models\TechnicianConsumable::lockForUpdate()->find($material->technician_consumable_id);if($source)$source->update(['qty_used'=>max(0,(float)$source->qty_used-(float)$material->qty),'qty_remaining'=>(float)$source->qty_remaining+(float)$material->qty,'status'=>'assigned']);}
if(isset($source)&&$source){\App\Models\MaterialMovement::create(['barcode_id'=>$source->barcode_id,'user_id'=>$request->user()->id,'to_user_id'=>$request->user()->id,'movement_type'=>'return','ticket_id'=>$ticket->id,'qty'=>$material->qty,'description'=>'Pembatalan pemakaian material pada ticket','created_by'=>$request->user()->id,'created_at'=>now()]);$warehouseBarcode=\App\Models\ItemBarcode::where('barcode',$source->barcode_id)->first();if($warehouseBarcode)\App\Models\ItemMovement::create(['tenant_id'=>$ticket->tenant_id,'warehouse_item_id'=>$warehouseBarcode->warehouse_item_id,'item_barcode_id'=>$warehouseBarcode->id,'movement_type'=>'ticket_usage_cancelled','qty'=>$material->qty,'reference_type'=>'ticket','reference_id'=>$ticket->id,'to_user_id'=>$request->user()->id,'notes'=>'Material dikembalikan ke saldo teknisi','created_by'=>$request->user()->id]);}
$material->delete();
});
return $this->deleted('Material ticket berhasil dihapus dan dikembalikan ke daftar bawaan user');
}
private function authorizeTenant(Request $request, Ticket $ticket): void
{
abort_unless($request->user()->isMasterAdmin() || (int) $ticket->tenant_id === (int) $request->attributes->get('tenant_id'), 403);
}
}
@@ -17,6 +17,8 @@ use App\Http\Requests\Ticket\CloseTicketRequest;
use App\Http\Requests\Ticket\CancelTicketRequest;
use App\Http\Requests\Ticket\EscalateTicketRequest;
use App\Http\Requests\Ticket\StartTicketRequest;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class TicketWorkflowController extends ApiController
{
@@ -29,6 +31,7 @@ class TicketWorkflowController extends ApiController
*/
public function history(Ticket $ticket)
{
$this->authorizeTicket($ticket);
$data = $this->service->history($ticket);
return $this->success(
@@ -44,6 +47,7 @@ class TicketWorkflowController extends ApiController
AssignTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->assign(
$ticket,
$request->technicians,
@@ -64,6 +68,7 @@ class TicketWorkflowController extends ApiController
ReassignTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->reassign(
$ticket,
$request->technicians,
@@ -84,6 +89,7 @@ class TicketWorkflowController extends ApiController
ApproveTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->approve(
$ticket,
$request->notes,
@@ -103,6 +109,7 @@ class TicketWorkflowController extends ApiController
RejectTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->reject(
$ticket,
$request->reason,
@@ -122,6 +129,7 @@ class TicketWorkflowController extends ApiController
StartTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->start(
$ticket,
$request->validated(),
@@ -141,10 +149,12 @@ class TicketWorkflowController extends ApiController
PendingTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->pending(
$ticket,
$request->reason,
auth()->id() ?? 1
auth()->id() ?? 1,
$request->validated('stored_file_ids')
);
return $this->success(
@@ -158,6 +168,7 @@ class TicketWorkflowController extends ApiController
*/
public function resume(Ticket $ticket)
{
$this->authorizeTicket($ticket);
$ticket = $this->service->resume(
$ticket,
auth()->id() ?? 1
@@ -176,10 +187,12 @@ class TicketWorkflowController extends ApiController
ResolveTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->resolve(
$ticket,
$request->notes,
auth()->id() ?? 1
auth()->id() ?? 1,
$request->validated()
);
return $this->success(
@@ -195,6 +208,7 @@ class TicketWorkflowController extends ApiController
CloseTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->close(
$ticket,
$request->notes,
@@ -207,6 +221,42 @@ class TicketWorkflowController extends ApiController
);
}
public function verify(Request $request, Ticket $ticket)
{
$this->authorizeTicket($ticket);
abort_unless($ticket->status === 'resolved', 422, 'Hanya ticket selesai yang dapat diverifikasi.');
$data=$request->validate(['notes'=>['required','string','max:2000']]);
$ticket->update(['verified_by'=>$request->user()->id,'verified_at'=>now(),'verification_notes'=>$data['notes']]);
\App\Models\TicketLog::create(['ticket_id'=>$ticket->id,'user_id'=>$request->user()->id,'activity'=>'Verified','notes'=>$data['notes'],'created_at'=>now()]);
return $this->updated(new TicketResource($ticket->fresh()),'Ticket berhasil diverifikasi');
}
public function installationStep(Request $request, Ticket $ticket, \App\Services\Customer\CustomerService $customers)
{
$this->authorizeTicket($ticket);
abort_unless($ticket->source === 'new_installation' && $ticket->customer_id,422,'Ticket bukan pemasangan baru.');
abort_unless(in_array($ticket->status,['progress','pending'],true),422,'Pekerjaan harus dimulai terlebih dahulu.');
$step=$request->validate(['step'=>['required',Rule::in(['documentation','service','activation'])]])['step'];
$expected=$ticket->installation_step ?: 'documentation'; abort_unless($step===$expected,422,"Tahap yang harus dikerjakan adalah {$expected}.");
$customer=$customers->find($ticket->customer_id);
if($step==='documentation'){
$data=$request->validate(['stored_file_ids'=>['required','array','min:1'],'stored_file_ids.*'=>['integer','exists:stored_files,id'],'topology_id'=>['required','integer','exists:topology_nodes,id'],'topology_port_number'=>['required','integer','min:1']]);
$images=collect($data['stored_file_ids'])->map(fn($id)=>['stored_file_id'=>$id,'category'=>'installation','is_cover'=>false])->all();
$customers->update($customer,['tenant_id'=>$customer->tenant_id,'topology_id'=>$data['topology_id'],'topology_port_number'=>$data['topology_port_number'],'images'=>$images],$request->user());
foreach($data['stored_file_ids'] as $id)\App\Models\TicketAttachment::firstOrCreate(['ticket_id'=>$ticket->id,'stored_file_id'=>$id,'stage'=>'installation_documentation'],['uploaded_by'=>$request->user()->id]);
$next='service';
}elseif($step==='service'){
$data=$request->validate(['package_profile_id'=>['required','integer','exists:nas_package_profiles,id'],'nas_mikrotik_id'=>['required','integer','exists:nas_mikrotiks,id'],'billing_profile_id'=>['required','integer','exists:billing_profiles,id']]);
$customers->update($customer,['tenant_id'=>$customer->tenant_id,...$data],$request->user()); $next='activation';
}else{
$data=$request->validate(['package_profile_id'=>['required','integer'],'nas_mikrotik_id'=>['required','integer'],'billing_profile_id'=>['required','integer'],'external_username'=>['required','string','max:255'],'external_password'=>['required','string','min:4'],'serial_number'=>['nullable','string','max:120'],'mac_address'=>['nullable','regex:/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/']]);
$customers->activate($customer,['activation_source'=>'ticket',...$data],$request->user()); $next='completed';
}
$ticket->update(['installation_step'=>$next]);
\App\Models\TicketLog::create(['ticket_id'=>$ticket->id,'user_id'=>$request->user()->id,'activity'=>'Tahap Pemasangan Selesai','notes'=>$step.' → '.$next,'created_at'=>now()]);
return $this->updated(new TicketResource($ticket->fresh()->load(['customer','attachments'])),'Tahap pemasangan berhasil disimpan');
}
/**
* Cancel
*/
@@ -214,6 +264,7 @@ class TicketWorkflowController extends ApiController
CancelTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->cancel(
$ticket,
$request->reason,
@@ -233,6 +284,7 @@ class TicketWorkflowController extends ApiController
EscalateTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->escalate(
$ticket,
$request->reason,
@@ -244,4 +296,11 @@ class TicketWorkflowController extends ApiController
'Ticket berhasil dieskalasi'
);
}
}
private function authorizeTicket(Ticket $ticket): void
{
$user = auth()->user();
$tenantId = request()->attributes->get('tenant_id');
abort_unless($user?->isMasterAdmin() || (int) $ticket->tenant_id === (int) $tenantId, 403);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Warehouse;
use App\Http\Controllers\ApiController;use App\Models\{ItemBarcode,ItemCode,ItemMovement,ItemStockLog,WarehouseItem};use Illuminate\Http\Request;use Illuminate\Support\Facades\DB;use Illuminate\Support\Str;use Illuminate\Validation\Rule;
class WarehouseBatchController extends ApiController
{
public function index(Request $r){$t=$r->attributes->get('tenant_id');$status=$r->input('status','active');$q=WarehouseItem::with(['warehouse:id,code,name','itemCode','barcodes'])->when(!$r->user()->isMasterAdmin()||$t,fn($x)=>$x->where('tenant_id',$t))->where('status',$status)->when($r->search,fn($x,$s)=>$x->where(fn($y)=>$y->where('item_number','ilike',"%$s%")->orWhereHas('itemCode',fn($z)=>$z->where('name','ilike',"%$s%")->orWhere('code','ilike',"%$s%"))))->orderByDesc('id')->paginate(min((int)$r->input('per_page',10),100));return $this->successPaginated($q,\Illuminate\Http\Resources\Json\JsonResource::class,'Daftar barang berhasil diambil');}
public function store(Request $r)
{
$t=$r->user()->isMasterAdmin()?($r->input('tenant_id')?:$r->attributes->get('tenant_id')):$r->attributes->get('tenant_id');
$d=$r->validate(['tenant_id'=>['nullable','integer','exists:tenants,id'],'warehouse_id'=>['required','integer','exists:warehouses,id'],'item_code_id'=>['required','integer','exists:item_codes,id'],'name'=>['required','string','max:255'],'quantity'=>['required','numeric','gt:0'],'package_count'=>['nullable','integer','min:1'],'received_at'=>['required','date'],'notes'=>['nullable','string']]);
$code=ItemCode::whereKey($d['item_code_id'])->where('tenant_id',$t)->firstOrFail();
abort_unless(\App\Models\Warehouse::whereKey($d['warehouse_id'])->where('tenant_id',$t)->exists(),422,'Gudang bukan milik tenant aktif.');
return DB::transaction(function()use($r,$d,$t,$code){
$base=$code->code.'-'.now()->format('dmy');
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))',[$t.'|'.$base]);
$seq=WarehouseItem::where('tenant_id',$t)->where('item_number','like',$base.'%')->count()+1;
do{$number=$base.'-'.str_pad((string)$seq++,3,'0',STR_PAD_LEFT);}while(WarehouseItem::where('tenant_id',$t)->where('item_number',$number)->exists());
$item=WarehouseItem::create(['tenant_id'=>$t,'warehouse_id'=>$d['warehouse_id'],'item_code_id'=>$code->id,'item_number'=>$number,'status'=>'draft','sku'=>$code->code,'name'=>$d['name'],'tracking_type'=>$code->tracking_type==='serialized'?'serialized':'bulk','unit'=>$code->unit,'minimum_stock'=>$code->minimum_stock,'received_qty'=>$d['quantity'],'received_at'=>$d['received_at'],'notes'=>$d['notes']??null,'is_active'=>false]);
$count=$code->tracking_type==='serialized'?(int)$d['quantity']:max(1,(int)($d['package_count']??1));
abort_if($code->tracking_type==='serialized'&&(float)$d['quantity']!==floor((float)$d['quantity']),422,'Barang serialized harus berjumlah bilangan bulat.');
$remaining=(float)$d['quantity'];
for($i=1;$i<=$count;$i++){$qty=$code->tracking_type==='serialized'?1:($i===$count?$remaining:round((float)$d['quantity']/$count,2));$remaining-=$qty;ItemBarcode::create(['tenant_id'=>$t,'warehouse_item_id'=>$item->id,'package_number'=>$i,'barcode'=>$this->barcode($code->code,$t),'tracking_type'=>$code->tracking_type,'unit'=>$code->unit,'initial_qty'=>$qty,'remaining_qty'=>$qty,'status'=>'draft']);}
return $this->created($item->load(['warehouse','itemCode','barcodes']),'Barang draft dan barcode berhasil dibuat');
});
}
public function show(Request $r,WarehouseItem $item){$this->auth($r,$item);return $this->success($item->load(['warehouse','itemCode','barcodes','movements']),'Detail barang berhasil diambil');}
public function updateSerials(Request $r,WarehouseItem $item){$this->auth($r,$item);abort_unless($item->status==='draft',422,'Serial number hanya dapat diubah pada draft.');$d=$r->validate(['barcodes'=>['required','array','min:1'],'barcodes.*.id'=>['required','integer'],'barcodes.*.serial_number'=>['nullable','string','max:120']]);DB::transaction(function()use($d,$item){foreach($d['barcodes'] as $row){$barcode=$item->barcodes()->findOrFail($row['id']);$serial=$row['serial_number']?:null;if($serial&&ItemBarcode::where('tenant_id',$item->tenant_id)->where('serial_number',$serial)->whereKeyNot($barcode->id)->exists())abort(422,"Serial number {$serial} sudah digunakan.");$barcode->update(['serial_number'=>$serial]);}});return $this->updated($item->fresh()->load('barcodes'),'Serial number berhasil diperbarui');}
public function post(Request $r,WarehouseItem $item){$this->auth($r,$item);abort_unless($item->status==='draft',422,'Hanya barang draft yang dapat diposting.');$item->load(['itemCode','barcodes']);abort_if($item->barcodes->isEmpty(),422,'Barcode belum tersedia.');if($item->itemCode->need_serial_number)abort_if($item->barcodes->contains(fn($b)=>blank($b->serial_number)),422,'Semua serial number wajib diisi sebelum posting.');return DB::transaction(function()use($r,$item){$item->barcodes()->update(['status'=>'available']);$item->update(['status'=>'active','is_active'=>true,'posted_by'=>$r->user()->id,'posted_at'=>now()]);$move=ItemMovement::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'movement_type'=>'stock_posted','qty'=>$item->received_qty,'notes'=>'Draft diposting menjadi stok aktif','created_by'=>$r->user()->id]);ItemStockLog::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_movement_id'=>$move->id,'qty_before'=>0,'qty_change'=>$item->received_qty,'qty_after'=>$item->received_qty]);return $this->updated($item->fresh()->load(['warehouse','itemCode','barcodes']),'Barang berhasil diposting ke Daftar Barang');});}
public function destroy(Request $r,WarehouseItem $item){$this->auth($r,$item);abort_unless($item->status==='draft',422,'Hanya barang draft yang dapat dihapus.');$item->delete();return $this->deleted('Draft barang dan barcode berhasil dihapus');}
public function barcodeMovements(Request $r,WarehouseItem $item,ItemBarcode $barcode){$this->auth($r,$item);abort_unless((int)$barcode->warehouse_item_id===(int)$item->id,404);return $this->success(ItemMovement::where('item_barcode_id',$barcode->id)->latest()->get(),'Histori barcode berhasil diambil');}
private function barcode(string $code,int $tenant):string{do{$random='';$chars='ABCDEFGHJKLMNPQRSTUVWXYZ23456789';for($i=0;$i<6;$i++)$random.=$chars[random_int(0,strlen($chars)-1)];$value=$code.'-'.now()->format('dmy').'-'.$random;}while(ItemBarcode::where('barcode',$value)->exists());return $value;}
private function auth(Request $r,WarehouseItem $i){abort_unless($r->user()->isMasterAdmin()||(int)$i->tenant_id===(int)$r->attributes->get('tenant_id'),403);}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Warehouse;
use App\Http\Controllers\ApiController;use App\Models\{WarehouseItem,ItemBarcode,ItemCode,ItemMovement,ItemStockLog};use Illuminate\Http\Request;use Illuminate\Support\Facades\DB;use Illuminate\Validation\Rule;
class WarehouseController extends ApiController
{
public function index(Request $r){$t=$r->attributes->get('tenant_id');$q=WarehouseItem::with(['itemCode','barcodes'])->when(!$r->user()->isMasterAdmin()||$t,fn($x)=>$x->where('tenant_id',$t))->when($r->search,fn($x,$s)=>$x->whereHas('itemCode',fn($y)=>$y->where('name','ilike',"%$s%")->orWhere('code','ilike',"%$s%")))->orderBy($r->input('sort_by','id'),$r->input('sort_direction','desc'))->paginate(min((int)$r->input('per_page',10),100));return $this->successPaginated($q,\Illuminate\Http\Resources\Json\JsonResource::class,'Daftar barang berhasil diambil');}
public function store(Request $r){$d=$this->validateItem($r);$d['tenant_id']=$r->user()->isMasterAdmin()?($d['tenant_id']??$r->attributes->get('tenant_id')):$r->attributes->get('tenant_id');return $this->created(WarehouseItem::create($d),'Barang berhasil dibuat');}
public function update(Request $r,WarehouseItem $item){$this->auth($r,$item);$item->update($this->validateItem($r,$item));return $this->updated($item->fresh(),'Barang berhasil diperbarui');}
public function destroy(Request $r,WarehouseItem $item){$this->auth($r,$item);abort_if($item->barcodes()->where('remaining_qty','>',0)->exists(),422,'Barang yang masih memiliki stok tidak dapat dihapus.');$item->delete();return $this->deleted('Barang berhasil dihapus');}
public function receive(Request $r,WarehouseItem $item){$this->auth($r,$item);$item->loadMissing('itemCode');$type=$item->itemCode?->tracking_type??($item->tracking_type==='serialized'?'serialized':'quantity');$needSerial=(bool)($item->itemCode?->need_serial_number);$d=$r->validate(['barcode'=>['required','string','max:120','unique:item_barcodes,barcode'],'serial_number'=>[$needSerial?'required':'nullable','string','max:120'],'qty'=>['required','numeric','gt:0'],'notes'=>['nullable','string']]);if($type==='serialized')$d['qty']=1;return DB::transaction(function()use($r,$item,$d,$type){$before=(float)$item->barcodes()->sum('remaining_qty');$barcode=ItemBarcode::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'barcode'=>$d['barcode'],'tracking_type'=>$type,'unit'=>$item->itemCode?->unit??$item->unit,'serial_number'=>$d['serial_number']??null,'initial_qty'=>$d['qty'],'remaining_qty'=>$d['qty'],'status'=>'available']);$move=ItemMovement::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_barcode_id'=>$barcode->id,'movement_type'=>'stock_in','qty'=>$d['qty'],'notes'=>$d['notes']??null,'created_by'=>$r->user()->id]);ItemStockLog::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_barcode_id'=>$barcode->id,'item_movement_id'=>$move->id,'qty_before'=>$before,'qty_change'=>$d['qty'],'qty_after'=>$before+$d['qty'],'barcode_qty_before'=>0,'barcode_qty_change'=>$d['qty'],'barcode_qty_after'=>$d['qty']]);return $this->created($barcode,'Stok masuk berhasil dicatat');});}
public function options(Request $r){$t=$r->attributes->get('tenant_id');$users=\App\Models\UserTenant::with('user:id,name')->where('tenant_id',$t)->get()->pluck('user')->filter()->unique('id')->values();$codes=ItemCode::where('tenant_id',$t)->where('is_active',true)->orderBy('name')->get();return $this->success(['users'=>$users,'item_codes'=>$codes],'Pilihan gudang berhasil diambil');}
public function issue(Request $r,ItemBarcode $barcode){$item=$barcode->item;$this->auth($r,$item);$d=$r->validate(['user_id'=>['required','integer','exists:users,id'],'qty'=>['required','numeric','gt:0'],'notes'=>['nullable','string','max:1000']]);abort_unless(\App\Models\UserTenant::where('tenant_id',$item->tenant_id)->where('user_id',$d['user_id'])->exists(),422,'Penerima bukan user tenant ini.');return DB::transaction(function()use($r,$item,$barcode,$d){$locked=ItemBarcode::whereKey($barcode->id)->lockForUpdate()->firstOrFail();$barcodeBefore=(float)$locked->remaining_qty;$serialized=($locked->tracking_type??$item->tracking_type)==='serialized';$qty=$serialized?1:(float)$d['qty'];abort_if($barcodeBefore<$qty,422,'Stok barcode tidak mencukupi.');$before=(float)$item->barcodes()->sum('remaining_qty');if($serialized){abort_if(\App\Models\TechnicianMaterial::where('barcode_id',$locked->barcode)->exists(),422,'Barcode sudah pernah dikeluarkan.');\App\Models\TechnicianMaterial::create(['user_id'=>$d['user_id'],'barcode_id'=>$locked->barcode,'inventory_item_id'=>$item->id,'material_name'=>$item->name,'serial_number'=>$locked->serial_number,'status'=>'assigned','assigned_at'=>now(),'assigned_by'=>$r->user()->id]);}else{abort_if(\App\Models\TechnicianConsumable::where('barcode_id',$locked->barcode)->exists(),422,'Satu barcode quantity hanya dapat diserahkan kepada satu user sampai dikembalikan.');\App\Models\TechnicianConsumable::create(['user_id'=>$d['user_id'],'barcode_id'=>$locked->barcode,'inventory_item_id'=>$item->id,'material_name'=>$item->name,'unit'=>$locked->unit??$item->unit,'qty_received'=>$qty,'qty_used'=>0,'qty_remaining'=>$qty,'status'=>'assigned','assigned_at'=>now(),'assigned_by'=>$r->user()->id]);}$barcodeAfter=$barcodeBefore-$qty;$locked->update(['remaining_qty'=>$barcodeAfter,'status'=>$barcodeAfter>0?'available':'issued']);$move=ItemMovement::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_barcode_id'=>$locked->id,'movement_type'=>'issue_to_technician','qty'=>$qty,'to_user_id'=>$d['user_id'],'notes'=>$d['notes']??null,'created_by'=>$r->user()->id]);ItemStockLog::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_barcode_id'=>$locked->id,'item_movement_id'=>$move->id,'qty_before'=>$before,'qty_change'=>-$qty,'qty_after'=>$before-$qty,'barcode_qty_before'=>$barcodeBefore,'barcode_qty_change'=>-$qty,'barcode_qty_after'=>$barcodeAfter]);return $this->created($move,'Barang berhasil dikeluarkan kepada user');});}
public function movements(Request $r){$t=$r->attributes->get('tenant_id');return $this->successPaginated(ItemMovement::when(!$r->user()->isMasterAdmin()||$t,fn($q)=>$q->where('tenant_id',$t))->latest()->paginate(min((int)$r->input('per_page',20),100)),\Illuminate\Http\Resources\Json\JsonResource::class,'Pergerakan barang berhasil diambil');}
public function technicianInventory(Request $r){$t=$r->attributes->get('tenant_id');$userIds=\App\Models\UserTenant::where('tenant_id',$t)->pluck('user_id');$serialized=\App\Models\TechnicianMaterial::with('user:id,name')->whereIn('user_id',$userIds)->where('status','assigned')->get()->map(fn($x)=>['id'=>'s-'.$x->id,'user'=>$x->user?->name,'material_name'=>$x->material_name,'barcode'=>$x->barcode_id,'tracking_type'=>'serialized','qty'=>1,'unit'=>'pcs','status'=>$x->status,'assigned_at'=>$x->assigned_at]);$quantity=\App\Models\TechnicianConsumable::with('user:id,name')->whereIn('user_id',$userIds)->where('status','assigned')->where('qty_remaining','>',0)->get()->map(fn($x)=>['id'=>'q-'.$x->id,'user'=>$x->user?->name,'material_name'=>$x->material_name,'barcode'=>$x->barcode_id,'tracking_type'=>'quantity','qty'=>$x->qty_remaining,'unit'=>$x->unit,'status'=>$x->status,'assigned_at'=>$x->assigned_at]);$all=$serialized->concat($quantity)->sortByDesc('assigned_at')->values();$page=max(1,(int)$r->input('page',1));$per=min(max(1,(int)$r->input('per_page',10)),100);$p=new \Illuminate\Pagination\LengthAwarePaginator($all->forPage($page,$per)->values(),$all->count(),$per,$page,['path'=>$r->url(),'query'=>$r->query()]);return $this->successPaginated($p,\Illuminate\Http\Resources\Json\JsonResource::class,'Peralatan teknisi berhasil diambil');}
public function returnFromTechnician(Request $r){$d=$r->validate(['source_type'=>['required',Rule::in(['serialized','quantity'])],'source_id'=>['required','integer'],'qty'=>['required','numeric','gt:0'],'notes'=>['nullable','string','max:1000']]);$t=$r->attributes->get('tenant_id');return DB::transaction(function()use($r,$d,$t){$source=$d['source_type']==='serialized'?\App\Models\TechnicianMaterial::lockForUpdate()->findOrFail($d['source_id']):\App\Models\TechnicianConsumable::lockForUpdate()->findOrFail($d['source_id']);abort_unless(\App\Models\UserTenant::where('tenant_id',$t)->where('user_id',$source->user_id)->exists(),403);$barcode=ItemBarcode::where('barcode',$source->barcode_id)->lockForUpdate()->firstOrFail();$item=$barcode->item;$qty=$d['source_type']==='serialized'?1:(float)$d['qty'];if($d['source_type']==='serialized'){abort_unless($source->status==='assigned',422,'Barang sudah tidak berada pada teknisi.');$source->update(['status'=>'returned']);}else{abort_if((float)$source->qty_remaining<$qty,422,'Jumlah pengembalian melebihi sisa teknisi.');$remaining=(float)$source->qty_remaining-$qty;$source->update(['qty_remaining'=>$remaining,'status'=>$remaining>0?'assigned':'returned']);}$itemBefore=(float)$item->barcodes()->sum('remaining_qty');$barcodeBefore=(float)$barcode->remaining_qty;$barcodeAfter=$barcodeBefore+$qty;$barcode->update(['remaining_qty'=>$barcodeAfter,'status'=>'available']);$move=ItemMovement::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_barcode_id'=>$barcode->id,'movement_type'=>'return_to_warehouse','qty'=>$qty,'from_user_id'=>$source->user_id,'notes'=>$d['notes']??null,'created_by'=>$r->user()->id]);ItemStockLog::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_barcode_id'=>$barcode->id,'item_movement_id'=>$move->id,'qty_before'=>$itemBefore,'qty_change'=>$qty,'qty_after'=>$itemBefore+$qty,'barcode_qty_before'=>$barcodeBefore,'barcode_qty_change'=>$qty,'barcode_qty_after'=>$barcodeAfter]);return $this->created($move,'Barang berhasil dikembalikan ke gudang');});}
public function codeIndex(Request $r){$t=$r->attributes->get('tenant_id');$q=ItemCode::when(!$r->user()->isMasterAdmin()||$t,fn($x)=>$x->where('tenant_id',$t))->when($r->search,fn($x,$s)=>$x->where(fn($y)=>$y->where('name','ilike',"%$s%")->orWhere('code','ilike',"%$s%")))->orderBy($r->input('sort_by','name'),$r->input('sort_direction','asc'))->paginate(min((int)$r->input('per_page',10),100));return $this->successPaginated($q,\Illuminate\Http\Resources\Json\JsonResource::class,'Kode barang berhasil diambil');}
public function codeStore(Request $r){$d=$this->validateCode($r);$d['tenant_id']=$r->user()->isMasterAdmin()?($d['tenant_id']??$r->attributes->get('tenant_id')):$r->attributes->get('tenant_id');$d['created_by']=$r->user()->id;return $this->created(ItemCode::create($d),'Kode barang berhasil dibuat');}
public function codeUpdate(Request $r,ItemCode $code){$this->authCode($r,$code);$code->update($this->validateCode($r,$code));return $this->updated($code->fresh(),'Kode barang berhasil diperbarui');}
public function codeDestroy(Request $r,ItemCode $code){$this->authCode($r,$code);abort_if($code->items()->exists(),422,'Kode barang yang sudah digunakan tidak dapat dihapus.');$code->delete();return $this->deleted('Kode barang berhasil dihapus');}
private function validateCode(Request $r,?ItemCode $code=null){$tenant=$r->input('tenant_id')?:$r->attributes->get('tenant_id');return $r->validate(['tenant_id'=>['nullable','integer','exists:tenants,id'],'code'=>['required','string','max:80',Rule::unique('item_codes')->where('tenant_id',$tenant)->ignore($code)],'name'=>['required','string','max:255'],'tracking_type'=>['required',Rule::in(['serialized','quantity'])],'need_serial_number'=>['boolean'],'unit'=>['required',Rule::in(['pcs','meter','lembar','roll','box','dus','set','unit'])],'minimum_stock'=>['nullable','numeric','min:0'],'description'=>['nullable','string'],'is_active'=>['boolean']]);}
private function validateItem(Request $r,?WarehouseItem $i=null){$tenant=$r->user()->isMasterAdmin()?($r->input('tenant_id')?:$r->attributes->get('tenant_id')):$r->attributes->get('tenant_id');$d=$r->validate(['tenant_id'=>['nullable','integer','exists:tenants,id'],'item_code_id'=>['required','integer','exists:item_codes,id',Rule::unique('warehouse_items')->where('tenant_id',$tenant)->ignore($i)],'is_active'=>['boolean']]);$code=ItemCode::findOrFail($d['item_code_id']);abort_unless((int)$code->tenant_id===(int)$tenant,422,'Kode barang bukan milik tenant aktif.');return [...$d,'sku'=>$code->code,'name'=>$code->name,'tracking_type'=>$code->tracking_type==='serialized'?'serialized':'bulk','unit'=>$code->unit,'minimum_stock'=>$code->minimum_stock];}
private function authCode(Request $r,ItemCode $code){abort_unless($r->user()->isMasterAdmin()||(int)$code->tenant_id===(int)$r->attributes->get('tenant_id'),403);}
private function auth(Request $r,WarehouseItem $i){abort_unless($r->user()->isMasterAdmin()||(int)$i->tenant_id===(int)$r->attributes->get('tenant_id'),403);}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers\Warehouse;
use App\Http\Controllers\ApiController;use App\Models\Warehouse;use Illuminate\Http\Request;use Illuminate\Validation\Rule;
class WarehouseLocationController extends ApiController
{
public function index(Request $r){$t=$r->attributes->get('tenant_id');$q=Warehouse::withCount('items')->when(!$r->user()->isMasterAdmin()||$t,fn($x)=>$x->where('tenant_id',$t))->when($r->search,fn($x,$s)=>$x->where(fn($y)=>$y->where('name','ilike',"%$s%")->orWhere('code','ilike',"%$s%")))->orderBy('name')->paginate(min((int)$r->input('per_page',10),100));return $this->successPaginated($q,\Illuminate\Http\Resources\Json\JsonResource::class,'Daftar gudang berhasil diambil');}
public function store(Request $r){$d=$this->data($r);$d['tenant_id']=$r->user()->isMasterAdmin()?($d['tenant_id']??$r->attributes->get('tenant_id')):$r->attributes->get('tenant_id');$d['created_by']=$r->user()->id;return $this->created(Warehouse::create($d),'Gudang berhasil dibuat');}
public function update(Request $r,Warehouse $warehouse){$this->auth($r,$warehouse);$warehouse->update($this->data($r,$warehouse));return $this->updated($warehouse->fresh(),'Gudang berhasil diperbarui');}
public function destroy(Request $r,Warehouse $warehouse){$this->auth($r,$warehouse);abort_if($warehouse->items()->exists(),422,'Gudang yang sudah memiliki barang tidak dapat dihapus.');$warehouse->delete();return $this->deleted('Gudang berhasil dihapus');}
private function data(Request $r,?Warehouse $w=null){$t=$r->input('tenant_id')?:$r->attributes->get('tenant_id');return $r->validate(['tenant_id'=>['nullable','integer','exists:tenants,id'],'code'=>['required','string','max:50',Rule::unique('warehouses')->where('tenant_id',$t)->ignore($w)],'name'=>['required','string','max:255'],'address'=>['nullable','string'],'is_active'=>['boolean']]);}
private function auth(Request $r,Warehouse $w){abort_unless($r->user()->isMasterAdmin()||(int)$w->tenant_id===(int)$r->attributes->get('tenant_id'),403);}
}
@@ -3,6 +3,7 @@
namespace App\Http\Requests\Customer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ActivateCustomerRequest extends FormRequest
{
@@ -14,11 +15,16 @@ class ActivateCustomerRequest extends FormRequest
public function rules(): array
{
return [
'activation_source' => ['required', Rule::in(['list_order', 'ticket'])],
'package_profile_id' => ['required', 'integer', 'exists:nas_package_profiles,id'],
'nas_mikrotik_id' => ['required', 'integer', 'exists:nas_mikrotiks,id'],
'billing_profile_id' => ['required', 'integer', 'exists:billing_profiles,id'],
'topology_id' => ['nullable', 'integer', 'min:1'],
'external_username' => ['nullable', 'string', 'max:255'],
'external_username' => ['required', 'string', 'max:255'],
'external_password' => ['required', 'string', 'min:4', 'max:255'],
'mac_address' => ['nullable', 'regex:/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/'],
'serial_number' => ['nullable', 'string', 'max:120'],
'assigned_ip' => ['nullable', 'ip'],
];
}
}
@@ -17,7 +17,6 @@ class StoreCustomerRequest extends FormRequest
return [
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
'user_id' => ['nullable', 'integer', 'exists:users,id'],
'customer_code' => ['nullable', 'string', 'max:100'],
'name' => ['required', 'string', 'max:255'],
'identity_number' => ['nullable', 'string', 'max:100'],
'email' => ['nullable', 'email', 'max:255'],
@@ -32,10 +31,10 @@ class StoreCustomerRequest extends FormRequest
'package_profile_id' => ['nullable', 'integer', 'exists:nas_package_profiles,id'],
'nas_mikrotik_id' => ['nullable', 'integer', 'exists:nas_mikrotiks,id'],
'billing_profile_id' => ['nullable', 'integer', 'min:1'],
'topology_id' => ['nullable', 'integer', 'min:1'],
'external_username' => ['nullable', 'string', 'max:255'],
'topology_id' => ['nullable', 'integer', 'exists:topology_nodes,id'],
'topology_port_number' => ['nullable', 'required_with:topology_id', 'integer', 'min:1'],
'sales_user_id' => ['nullable', 'integer', 'exists:users,id'],
'source' => ['nullable', Rule::in(['manual', 'mikrotik', 'radius'])],
'order_stage' => ['nullable', Rule::in(['registration', 'data_completion', 'ready_activation', 'activation_failed'])],
'notes' => ['nullable', 'string'],
'images' => ['nullable', 'array', 'max:20'],
'images.*.stored_file_id' => ['required', 'integer', 'exists:stored_files,id'],
@@ -49,6 +49,7 @@ class SyncMenuGroupMenusRequest extends FormRequest
'nullable',
'boolean',
],
'menus.*.can_activate' => ['nullable','boolean'],
'menus.*.can_export' => [
'nullable',
'boolean',
@@ -18,7 +18,7 @@ class IndexNasResourceRequest extends FormRequest
'search' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', Rule::in(['active', 'inactive'])],
'connection_type' => ['nullable', Rule::in(['api', 'radius'])],
'service_type' => ['nullable', Rule::in(['ppp', 'hotspot', 'radius'])],
'service_type' => ['nullable', Rule::in(['ppp', 'hotspot', 'static_ip', 'dynamic_ip'])],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'sort_by' => ['nullable', Rule::in(['id', 'name', 'host', 'connection_type', 'service_type', 'vendor', 'device_type', 'status', 'created_at'])],
@@ -17,17 +17,42 @@ class StoreNasResourceRequest extends FormRequest
return array_merge($this->commonRules(), match ($this->route()->defaults['nas_resource'] ?? null) {
'mikrotik' => [
'connection_type' => ['required', Rule::in(['api', 'radius'])],
'backend_mode_override' => ['required', Rule::in(['routeros_api','platform_radius','external_radius'])],
'host' => ['required', 'string', 'max:255'],
'nas_identifier' => ['nullable', 'string', 'max:255'],
'nas_ip_address' => ['nullable', 'ip'],
'radius_host' => ['nullable', 'string', 'max:255'],
'api_port' => ['nullable', 'integer', 'between:1,65535'],
'api_username' => ['nullable', 'string', 'max:255'],
'api_password' => ['nullable', 'string', 'max:1000'],
'radius_auth_port' => ['nullable', 'integer', 'between:1,65535'],
'radius_accounting_port' => ['nullable', 'integer', 'between:1,65535'],
'radius_coa_port' => ['nullable', 'integer', 'between:1,65535'],
'radius_secret' => ['nullable', 'string', 'max:1000'],
'timeout' => ['nullable', 'integer', 'between:1,300'],
],
'package-profile' => [
'service_type' => ['required', Rule::in(['ppp', 'hotspot', 'radius'])],
'service_type' => ['required', Rule::in(['ppp', 'hotspot', 'static_ip', 'dynamic_ip'])],
'profile_code' => ['required', 'alpha_dash', 'max:80'],
'usage_mode' => ['nullable', Rule::in(['one_time', 'periodic', 'recurring_member'])],
'validity_start' => ['nullable', Rule::in(['activation', 'first_login'])],
'validity_value' => ['nullable', 'integer', 'min:1'],
'validity_unit' => ['nullable', Rule::in(['minutes', 'hours', 'days', 'months'])],
'uptime_limit_seconds' => ['nullable', 'integer', 'min:0'],
'data_limit_bytes' => ['nullable', 'integer', 'min:0'],
'grace_period_minutes' => ['nullable', 'integer', 'min:0', 'max:10080'],
'mac_lock_required' => ['nullable', 'boolean'],
'ip_pool' => ['nullable', 'string', 'max:255'],
'subnet_cidr' => ['nullable', 'string', 'max:64'],
'gateway' => ['nullable', 'ip'],
'dns_servers' => ['nullable', 'array', 'max:4'],
'dns_servers.*' => ['required', 'ip'],
'vlan_id' => ['nullable', 'integer', 'between:1,4094'],
'service_parameters' => ['nullable', 'array'],
'scripts' => ['nullable', 'array'],
'scripts.*.policy_id' => ['required', 'integer', 'exists:nas_script_policies,id'],
'scripts.*.version_id' => ['nullable', 'integer', 'exists:nas_script_policy_versions,id'],
'scripts.*.event' => ['required', Rule::in(['on_up','on_down','on_login','on_logout'])],
'external_profile_name' => ['nullable', 'string', 'max:255'],
'download_kbps' => ['nullable', 'integer', 'min:0'],
'upload_kbps' => ['nullable', 'integer', 'min:0'],
@@ -72,4 +97,42 @@ class StoreNasResourceRequest extends FormRequest
'notes' => ['nullable', 'string'],
];
}
public function withValidator($validator): void
{
$resource = $this->route()->defaults['nas_resource'] ?? null;
if ($resource === 'mikrotik') {
$validator->after(function ($validator) {
$mode = $this->input('backend_mode_override');
if (! $this->input('api_username')) $validator->errors()->add('api_username', 'API Username wajib diisi untuk monitoring Mikrotik.');
if ($this->isMethod('post') && ! $this->input('api_password')) $validator->errors()->add('api_password', 'API Password wajib diisi.');
if ($mode !== 'routeros_api') {
if (! $this->input('nas_identifier')) $validator->errors()->add('nas_identifier', 'NAS Identifier wajib diisi untuk integrasi RADIUS.');
if (! $this->input('nas_ip_address')) $validator->errors()->add('nas_ip_address', 'IP NAS wajib diisi untuk integrasi RADIUS.');
if ($this->isMethod('post') && ! $this->input('radius_secret')) $validator->errors()->add('radius_secret', 'Shared secret khusus NAS wajib diisi.');
if ($mode === 'external_radius' && ! $this->input('radius_host')) $validator->errors()->add('radius_host', 'Host RADIUS Eksternal wajib diisi.');
}
});
return;
}
if ($resource !== 'package-profile') return;
$validator->after(function ($validator) {
$type = $this->input('service_type');
$mode = $this->input('usage_mode');
if ($type === 'hotspot' && ! in_array($mode, ['one_time', 'periodic', 'recurring_member'], true)) {
$validator->errors()->add('usage_mode', 'Mode pemakaian wajib dipilih untuk profile Hotspot.');
}
if ($type === 'hotspot' && in_array($mode, ['one_time', 'periodic', 'recurring_member'], true)
&& (! $this->input('validity_value') || ! $this->input('validity_unit'))) {
$validator->errors()->add('validity_value', 'Durasi dan satuan masa aktif wajib diisi untuk profile Hotspot.');
}
if (in_array($type, ['static_ip', 'dynamic_ip'], true)) {
if (! $this->boolean('mac_lock_required')) $validator->errors()->add('mac_lock_required', 'Penguncian MAC wajib aktif untuk profile IP Static/Dinamis.');
if (! $this->input('subnet_cidr')) $validator->errors()->add('subnet_cidr', 'Subnet CIDR wajib diisi.');
if (! $this->input('gateway')) $validator->errors()->add('gateway', 'Gateway wajib diisi.');
if ($type === 'dynamic_ip' && ! $this->input('ip_pool')) $validator->errors()->add('ip_pool', 'IP Pool wajib diisi untuk IP Dinamis.');
}
});
}
}
@@ -24,7 +24,8 @@ class AssignTicketRequest extends FormRequest
{
return [
'technicians' => 'required|array|min:1',
'leader_id' => 'required|integer',
'technicians.*' => 'required|integer|exists:users,id',
'leader_id' => 'required|integer|in_array:technicians.*',
];
}
}
@@ -32,6 +32,9 @@ class IndexTicketRequest extends FormRequest
'page' => ['nullable', 'integer', 'min:1'],
/** Jumlah data per halaman, maksimal 100. */
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'sort_by' => ['nullable', Rule::in(['ticket_no', 'title', 'priority', 'status', 'created_at'])],
'sort_direction' => ['nullable', Rule::in(['asc', 'desc'])],
'assigned_to_me' => ['nullable', 'boolean'],
];
}
}
@@ -23,7 +23,9 @@ class PendingTicketRequest extends FormRequest
public function rules(): array
{
return [
'reason' => 'required|string|max:1000'
'reason' => 'required|string|max:1000',
'stored_file_ids' => ['required','array','min:1'],
'stored_file_ids.*' => ['integer','exists:stored_files,id'],
];
}
}
@@ -23,7 +23,9 @@ class ResolveTicketRequest extends FormRequest
public function rules(): array
{
return [
'notes' => 'required|string|max:2000'
'notes' => 'required|string|max:2000',
'stored_file_ids' => ['required','array','min:1'],
'stored_file_ids.*' => ['integer','exists:stored_files,id'],
];
}
}
@@ -25,7 +25,9 @@ class StartTicketRequest extends FormRequest
return [
'latitude' => 'nullable|numeric',
'longitude' => 'nullable|numeric',
'notes' => 'nullable|string|max:1000'
'notes' => 'nullable|string|max:1000',
'stored_file_ids' => ['required','array','min:1'],
'stored_file_ids.*' => ['integer','exists:stored_files,id'],
];
}
}
@@ -23,8 +23,9 @@ class StoreTicketRequest extends FormRequest
public function rules(): array
{
return [
'ticket_type_id' => 'required|integer',
'customer_id' => 'nullable|integer',
'tenant_id' => 'nullable|integer|exists:tenants,id',
'ticket_type_id' => 'required|integer|exists:ticket_types,id',
'customer_id' => 'nullable|integer|exists:customers,id',
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'priority' => 'required|in:low,medium,high,critical'
+18
View File
@@ -10,6 +10,7 @@ class NasResource extends JsonResource
public function toArray(Request $request): array
{
$data = $this->resource->attributesToArray();
if ($this->resource instanceof \App\Models\NasMikrotik) $data['effective_backend_mode'] = $this->resource->effectiveBackendMode();
unset($data['api_password'], $data['radius_secret'], $data['community'], $data['auth_password'], $data['privacy_password'], $data['password']);
$data['tenant'] = $this->whenLoaded('tenant', fn () => [
@@ -17,6 +18,23 @@ class NasResource extends JsonResource
'tenant_code' => $this->tenant->tenant_code,
'tenant_name' => $this->tenant->tenant_name,
]);
$data['scripts'] = $this->whenLoaded('scripts', fn () => $this->scripts->map(fn ($policy) => [
'id' => $policy->id,
'code' => $policy->code,
'name' => $policy->name,
'service_type' => $policy->service_type,
'event' => $policy->event,
'current_version' => $policy->current_version,
'latest_version' => $policy->relationLoaded('latestVersion') && $policy->latestVersion ? [
'id' => $policy->latestVersion->id,
'version' => $policy->latestVersion->version,
] : null,
'pivot' => [
'event' => $policy->pivot->event,
'nas_script_policy_version_id' => $policy->pivot->nas_script_policy_version_id,
'enabled' => (bool) $policy->pivot->enabled,
],
]));
foreach ([
'api_password' => 'has_api_password',
@@ -23,9 +23,26 @@ class TicketResource extends JsonResource
'description' => $this->description,
'priority' => $this->priority,
'status' => $this->status,
'source' => $this->source,
'installation_step' => $this->installation_step,
'verified_by' => $this->verified_by,
'verified_at' => $this->verified_at,
'verification_notes' => $this->verification_notes,
'created_by' => $this->created_by,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'tenant_id' => $this->tenant_id,
'sla_minutes' => $this->sla_minutes,
'assigned_at' => $this->assigned_at,
'resolved_at' => $this->resolved_at,
'closed_at' => $this->closed_at,
'ticket_type' => $this->whenLoaded('ticketType'),
'tenant' => $this->whenLoaded('tenant'),
'customer' => $this->whenLoaded('customer'),
'creator' => $this->whenLoaded('creator'),
'assignments' => $this->whenLoaded('assignments'),
'materials' => $this->whenLoaded('materials'),
'attachments' => $this->whenLoaded('attachments'),
];
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Jobs;
use App\Models\NasMikrotik;
use App\Services\Nas\MikrotikHealthService;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
class CheckMikrotikConnection implements ShouldBeUnique, ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 25;
public int $uniqueFor = 240;
public function __construct(public int $mikrotikId)
{
$this->onQueue(config('nas.health.queue', 'nas-health'));
}
public function uniqueId(): string
{
return (string) $this->mikrotikId;
}
public function handle(MikrotikHealthService $health): void
{
$mikrotik = NasMikrotik::whereKey($this->mikrotikId)->where('status', 'active')->first();
if (! $mikrotik) {
return;
}
try {
$health->check($mikrotik);
} catch (Throwable) {
// Health service has persisted the offline state and next backoff time.
}
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Jobs;
use App\Models\CustomerNasAccount;
use App\Services\Nas\CustomerAccountReconciliationService;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
class ReconcileCustomerNasAccount implements ShouldQueue, ShouldBeUnique
{
use Queueable; public int $tries=1; public int $timeout=30; public int $uniqueFor=240;
public function __construct(public int $accountId){$this->onQueue('nas-reconciliation');}
public function uniqueId(): string{return (string)$this->accountId;}
public function handle(CustomerAccountReconciliationService $service): void { $a=CustomerNasAccount::find($this->accountId); if(!$a)return; try{$service->reconcile($a);}catch(Throwable $e){$a->update(['last_reconciled_at'=>now(),'last_sync_error'=>$e->getMessage()]);} }
}
+7
View File
@@ -31,6 +31,8 @@ class Customer extends Model
return $this->belongsTo(User::class);
}
public function salesUser() { return $this->belongsTo(User::class, 'sales_user_id'); }
public function provinsi()
{
return $this->belongsTo(WilayahProvinsi::class, 'provinsi_id');
@@ -61,6 +63,11 @@ class Customer extends Model
return $this->belongsTo(NasMikrotik::class, 'nas_mikrotik_id');
}
public function nasAccount()
{
return $this->hasOne(CustomerNasAccount::class);
}
public function images()
{
return $this->hasMany(CustomerImage::class)->orderByDesc('is_cover')->orderBy('sort_order');
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CustomerNasAccount extends Model
{
protected $guarded = ['id'];
protected $hidden = ['password'];
protected function casts(): array
{
return [
'password' => 'encrypted',
'last_synced_at' => 'datetime',
'validity_started_at' => 'datetime', 'first_login_at' => 'datetime', 'expires_at' => 'datetime',
'last_reconciled_at' => 'datetime', 'remote_state' => 'array',
];
}
public function customer()
{
return $this->belongsTo(Customer::class);
}
public function mikrotik()
{
return $this->belongsTo(NasMikrotik::class, 'nas_mikrotik_id');
}
public function packageProfile()
{
return $this->belongsTo(NasPackageProfile::class, 'nas_package_profile_id');
}
public function packageProfileTarget()
{
return $this->belongsTo(NasPackageProfileTarget::class, 'nas_package_profile_target_id');
}
}
+4
View File
@@ -0,0 +1,4 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ItemBarcode extends Model { protected $guarded=['id']; protected function casts():array{return ['initial_qty'=>'decimal:2','remaining_qty'=>'decimal:2'];} protected static function booted():void{static::updated(function(self $barcode){if(!$barcode->wasChanged('remaining_qty'))return;$item=$barcode->item;if(!$item||$item->status==='draft')return;$remaining=(float)$item->barcodes()->sum('remaining_qty');$item->update(['status'=>$remaining>0?'active':'depleted','is_active'=>$remaining>0]);});} public function item(){return $this->belongsTo(WarehouseItem::class,'warehouse_item_id');} }
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ItemCode extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return ['need_serial_number' => 'boolean', 'minimum_stock' => 'decimal:2', 'is_active' => 'boolean'];
}
public function items() { return $this->hasMany(WarehouseItem::class); }
}
+4
View File
@@ -0,0 +1,4 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ItemMovement extends Model { protected $guarded=['id']; protected function casts():array{return ['qty'=>'decimal:2'];} }
+4
View File
@@ -0,0 +1,4 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ItemStockLog extends Model { protected $guarded=['id']; protected function casts():array{return ['qty_before'=>'decimal:2','qty_change'=>'decimal:2','qty_after'=>'decimal:2','barcode_qty_before'=>'decimal:2','barcode_qty_change'=>'decimal:2','barcode_qty_after'=>'decimal:2'];} }
+2
View File
@@ -16,6 +16,7 @@ class MenuGroupMenu extends Model
'can_update',
'can_delete',
'can_approve',
'can_activate',
'can_export',
];
@@ -25,6 +26,7 @@ class MenuGroupMenu extends Model
'can_update' => 'boolean',
'can_delete' => 'boolean',
'can_approve' => 'boolean',
'can_activate' => 'boolean',
'can_export' => 'boolean',
];
+18 -1
View File
@@ -12,11 +12,28 @@ class NasMikrotik extends Model
protected function casts(): array
{
return ['api_password' => 'encrypted', 'radius_secret' => 'encrypted'];
return [
'api_password' => 'encrypted',
'radius_secret' => 'encrypted',
'last_checked_at' => 'datetime',
'last_connected_at' => 'datetime',
'next_health_check_at' => 'datetime',
'health_details' => 'array',
];
}
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
public function customerAccounts()
{
return $this->hasMany(CustomerNasAccount::class);
}
public function effectiveBackendMode(): string
{
return $this->backend_mode_override ?: ($this->tenant->nasSetting?->backend_mode ?: 'routeros_api');
}
}
+4 -1
View File
@@ -10,11 +10,14 @@ class NasPackageProfile extends Model
protected function casts(): array
{
return ['price' => 'decimal:2'];
return ['price' => 'decimal:2', 'service_parameters' => 'array', 'dns_servers' => 'array', 'mac_lock_required' => 'boolean'];
}
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
public function targets() { return $this->hasMany(NasPackageProfileTarget::class); }
public function scripts() { return $this->belongsToMany(NasScriptPolicy::class, 'nas_package_profile_scripts')->withPivot(['event', 'nas_script_policy_version_id', 'enabled']); }
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class NasPackageProfileTarget extends Model
{
protected $guarded = ['id'];
protected function casts(): array { return ['desired_snapshot' => 'array', 'remote_snapshot' => 'array', 'last_checked_at' => 'datetime', 'last_synced_at' => 'datetime']; }
public function packageProfile() { return $this->belongsTo(NasPackageProfile::class, 'nas_package_profile_id'); }
public function mikrotik() { return $this->belongsTo(NasMikrotik::class, 'nas_mikrotik_id'); }
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class NasScriptPolicy extends Model
{
protected $guarded = ['id'];
protected function casts(): array { return ['allowed_variables' => 'array', 'enabled' => 'boolean']; }
public function versions() { return $this->hasMany(NasScriptPolicyVersion::class); }
public function latestVersion() { return $this->hasOne(NasScriptPolicyVersion::class)->ofMany('version', 'max'); }
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class NasScriptPolicyVersion extends Model
{
protected $guarded = ['id'];
public function policy() { return $this->belongsTo(NasScriptPolicy::class, 'nas_script_policy_id'); }
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class RadiusCluster extends Model
{
protected $guarded = ['id'];
protected $hidden = ['provisioning_api_token'];
protected function casts(): array { return ['auth_endpoints'=>'array','accounting_endpoints'=>'array','coa_endpoints'=>'array','provisioning_api_token'=>'encrypted','last_checked_at'=>'datetime']; }
}
+3 -1
View File
@@ -21,4 +21,6 @@ class TechnicianConsumable extends Model
'assigned_at',
'assigned_by'
];
}
public function user() { return $this->belongsTo(User::class); }
}
+3 -1
View File
@@ -18,4 +18,6 @@ class TechnicianMaterial extends Model
'assigned_at',
'assigned_by',
];
}
public function user() { return $this->belongsTo(User::class); }
}
+3
View File
@@ -24,4 +24,7 @@ class Tenant extends Model
{
return $this->hasOne(TenantPaymentSetting::class);
}
public function nasSetting() { return $this->hasOne(TenantNasSetting::class); }
public function installationSetting() { return $this->hasOne(TenantInstallationSetting::class); }
}
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace App\Models;use Illuminate\Database\Eloquent\Model;
class TenantInstallationSetting extends Model{protected $fillable=['tenant_id','activation_mode','ktp_ocr_enabled','ocr_provider','ocr_api_key'];protected $hidden=['ocr_api_key'];protected function casts():array{return['ktp_ocr_enabled'=>'boolean','ocr_api_key'=>'encrypted'];}public function tenant(){return $this->belongsTo(Tenant::class);}}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TenantNasSetting extends Model
{
protected $guarded = ['id'];
protected $hidden = ['external_radius_secret','external_provisioning_api_token'];
protected function casts(): array { return ['external_radius_secret'=>'encrypted','external_provisioning_api_token'=>'encrypted','accounting_enabled'=>'boolean','coa_enabled'=>'boolean','last_checked_at'=>'datetime']; }
public function tenant() { return $this->belongsTo(Tenant::class); }
public function radiusCluster() { return $this->belongsTo(RadiusCluster::class); }
}
+21
View File
@@ -13,6 +13,7 @@ class Ticket extends Model
'ticket_no',
'ticket_type_id',
'customer_id',
'source','installation_step','verified_by','verified_at','verification_notes',
'title',
'description',
'priority',
@@ -26,8 +27,19 @@ class Ticket extends Model
'rejection_reason',
'resolved_at',
'closed_at',
'tenant_id',
];
protected function casts(): array
{
return [
'assigned_at' => 'datetime',
'resolved_at' => 'datetime',
'closed_at' => 'datetime',
'verified_at' => 'datetime',
];
}
/*
|--------------------------------------------------------------------------
| Relationships
@@ -41,4 +53,13 @@ class Ticket extends Model
'ticket_type_id'
);
}
public function customer() { return $this->belongsTo(Customer::class); }
public function tenant() { return $this->belongsTo(Tenant::class); }
public function creator() { return $this->belongsTo(User::class, 'created_by'); }
public function assignments() { return $this->hasMany(TicketAssignment::class); }
public function logs() { return $this->hasMany(TicketLog::class)->orderByDesc('id'); }
public function materials() { return $this->hasMany(TicketMaterial::class)->with('user:id,name')->orderByDesc('id'); }
public function attachments() { return $this->hasMany(TicketAttachment::class)->with('file:id,uuid,original_name,mime_type,size')->orderBy('id'); }
}
+3
View File
@@ -16,4 +16,7 @@ class TicketAssignment extends Model
'assigned_at',
'status'
];
protected function casts(): array { return ['is_leader' => 'boolean', 'assigned_at' => 'datetime']; }
public function user() { return $this->belongsTo(User::class); }
}
+4
View File
@@ -0,0 +1,4 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TicketAttachment extends Model { protected $guarded=['id']; public function file(){return $this->belongsTo(StoredFile::class,'stored_file_id');} public function uploader(){return $this->belongsTo(User::class,'uploaded_by');} }
+4 -1
View File
@@ -6,5 +6,8 @@ use Illuminate\Database\Eloquent\Model;
class TicketMaterial extends Model
{
//
public $timestamps = false;
protected $fillable=['ticket_id','ticket_log_id','technician_material_id','technician_consumable_id','material_name','qty','notes','used_by','created_at'];
protected function casts():array{return ['qty'=>'decimal:2','created_at'=>'datetime'];}
public function user(){return $this->belongsTo(User::class,'used_by');}
}
+1
View File
@@ -12,6 +12,7 @@ class UserTenant extends Model
'user_id',
'tenant_id',
'role',
'department',
'is_default',
];
+4
View File
@@ -0,0 +1,4 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Warehouse extends Model { protected $guarded=['id']; protected function casts():array{return ['is_active'=>'boolean'];} public function items(){return $this->hasMany(WarehouseItem::class);} }
+4
View File
@@ -0,0 +1,4 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WarehouseItem extends Model { protected $guarded=['id']; protected function casts():array{return ['is_active'=>'boolean','minimum_stock'=>'decimal:2','received_qty'=>'decimal:2','received_at'=>'date','posted_at'=>'datetime'];} public function warehouse(){return $this->belongsTo(Warehouse::class);} public function itemCode(){return $this->belongsTo(ItemCode::class);} public function barcodes(){return $this->hasMany(ItemBarcode::class);} public function movements(){return $this->hasMany(ItemMovement::class)->latest();} }
+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."]);
}
}
}
}
+1
View File
@@ -7,6 +7,7 @@
"license": "MIT",
"require": {
"php": "^8.2",
"evilfreelancer/routeros-api-php": "^1.7",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1",
Generated
+139 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "43e4a419e3226169717b5475e08cf4d8",
"content-hash": "ca5e5f70031e6c8e59d5d2a917415635",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -659,6 +659,85 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "evilfreelancer/routeros-api-php",
"version": "1.7.1",
"source": {
"type": "git",
"url": "https://github.com/EvilFreelancer/routeros-api-php.git",
"reference": "5351c0c622e06c73e1c64caca453240328b486d4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/EvilFreelancer/routeros-api-php/zipball/5351c0c622e06c73e1c64caca453240328b486d4",
"reference": "5351c0c622e06c73e1c64caca453240328b486d4",
"shasum": ""
},
"require": {
"ext-sockets": "*",
"php": "^7.4|^8.0",
"spatie/ssh": "^1.8"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"orchestra/testbench": "^8.0",
"phpunit/phpunit": "^9.6",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"RouterOS": "RouterOS\\Laravel\\Facade"
},
"providers": [
"RouterOS\\Laravel\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"RouterOS\\": "./src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Rock",
"email": "paul@drteam.rocks",
"homepage": "http://drteam.rocks/",
"role": "Developer"
}
],
"description": "Modern Mikrotik RouterOS API PHP client for your applications (with Laravel support)",
"keywords": [
"PSR-4",
"facade",
"laravel",
"mikrotik",
"plugin",
"routeros",
"socket-client"
],
"support": {
"issues": "https://github.com/EvilFreelancer/routeros-api-php/issues",
"source": "https://github.com/EvilFreelancer/routeros-api-php/tree/1.7.1"
},
"funding": [
{
"url": "https://boosty.to/evilfreelancer",
"type": "custom"
},
{
"url": "https://pay.cloudtips.ru/p/937f48ac",
"type": "custom"
}
],
"time": "2026-06-08T14:26:40+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.4.0",
@@ -3635,6 +3714,65 @@
},
"time": "2025-12-14T04:43:48+00:00"
},
{
"name": "spatie/ssh",
"version": "1.13.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/ssh.git",
"reference": "3335ab102d1ad3e2cabc4e1079cd8b03f59bcd64"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/ssh/zipball/3335ab102d1ad3e2cabc4e1079cd8b03f59bcd64",
"reference": "3335ab102d1ad3e2cabc4e1079cd8b03f59bcd64",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0",
"symfony/process": "^4.4|^5.3|^6.0|^7.0|^8.0"
},
"require-dev": {
"pestphp/pest": "^1.22",
"spatie/pest-plugin-snapshots": "^1.1",
"symfony/var-dumper": "^5.3|^6.0|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Ssh\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "A lightweight package to execute commands over an SSH connection",
"homepage": "https://github.com/spatie/ssh",
"keywords": [
"spatie",
"ssh"
],
"support": {
"issues": "https://github.com/spatie/ssh/issues",
"source": "https://github.com/spatie/ssh/tree/1.13.2"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
}
],
"time": "2026-07-24T12:17:04+00:00"
},
{
"name": "symfony/clock",
"version": "v7.4.8",
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
'health' => [
'queue' => env('NAS_HEALTH_QUEUE', 'nas-health'),
'online_interval_minutes' => (int) env('NAS_HEALTH_ONLINE_INTERVAL', 5),
'offline_max_interval_minutes' => (int) env('NAS_HEALTH_OFFLINE_MAX_INTERVAL', 30),
],
];
@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const ROOT_ORDER = [
'users' => 1,
'tenants' => 2,
'ticketing' => 3,
'wilayah' => 4,
'nas' => 5,
'customers' => 6,
'billing' => 7,
'topology' => 8,
'notifications-service' => 9,
'payment-gateway' => 10,
'wallet' => 11,
'voucher-hotspot' => 12,
'role-applications' => 13,
];
public function up(): void
{
foreach (self::ROOT_ORDER as $slug => $sortOrder) {
DB::table('menu_lists')
->whereNull('parent_id')
->where('slug', $slug)
->update(['sort_order' => $sortOrder]);
}
}
public function down(): void
{
foreach ([
'payment-gateway' => 80,
'wallet' => 81,
'voucher-hotspot' => 82,
'role-applications' => 83,
] as $slug => $sortOrder) {
DB::table('menu_lists')
->whereNull('parent_id')
->where('slug', $slug)
->update(['sort_order' => $sortOrder]);
}
}
};
@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const WORKFLOW_ORDER = [
'customers' => 1,
'billing' => 2,
'voucher-hotspot' => 3,
'nas' => 4,
'topology' => 5,
'ticketing' => 6,
'notifications-service' => 7,
'wallet' => 8,
'payment-gateway' => 9,
'users' => 10,
'tenants' => 11,
'role-applications' => 12,
'wilayah' => 13,
];
public function up(): void
{
$this->apply(self::WORKFLOW_ORDER);
}
public function down(): void
{
$this->apply([
'users' => 1,
'tenants' => 2,
'ticketing' => 3,
'wilayah' => 4,
'nas' => 5,
'customers' => 6,
'billing' => 7,
'topology' => 8,
'notifications-service' => 9,
'payment-gateway' => 10,
'wallet' => 11,
'voucher-hotspot' => 12,
'role-applications' => 13,
]);
}
private function apply(array $order): void
{
foreach ($order as $slug => $sortOrder) {
DB::table('menu_lists')
->whereNull('parent_id')
->where('slug', $slug)
->update(['sort_order' => $sortOrder]);
}
}
};
@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('nas_mikrotiks', function (Blueprint $table) {
$table->string('connection_status', 20)->default('unchecked')->after('status');
$table->timestamp('last_checked_at')->nullable()->after('connection_status');
$table->timestamp('last_connected_at')->nullable()->after('last_checked_at');
$table->unsignedInteger('last_latency_ms')->nullable()->after('last_connected_at');
$table->unsignedInteger('consecutive_failures')->default(0)->after('last_latency_ms');
$table->string('router_identity')->nullable()->after('consecutive_failures');
$table->string('router_version')->nullable()->after('router_identity');
$table->text('last_connection_error')->nullable()->after('router_version');
$table->index(['connection_type', 'status', 'connection_status'], 'nas_mikrotiks_health_index');
});
Schema::create('customer_nas_accounts', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('customer_id')->unique()->constrained()->cascadeOnDelete();
$table->foreignId('nas_mikrotik_id')->constrained()->restrictOnDelete();
$table->foreignId('nas_package_profile_id')->constrained()->restrictOnDelete();
$table->string('service_type', 20);
$table->string('username');
$table->text('password');
$table->string('remote_id')->nullable();
$table->string('remote_profile')->nullable();
$table->string('status', 20)->default('active');
$table->timestamp('last_synced_at')->nullable();
$table->text('last_sync_error')->nullable();
$table->timestamps();
$table->unique(['nas_mikrotik_id', 'service_type', 'username'], 'customer_nas_account_username_unique');
$table->index(['tenant_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('customer_nas_accounts');
Schema::table('nas_mikrotiks', function (Blueprint $table) {
$table->dropIndex('nas_mikrotiks_health_index');
$table->dropColumn([
'connection_status', 'last_checked_at', 'last_connected_at', 'last_latency_ms',
'consecutive_failures', 'router_identity', 'router_version', 'last_connection_error',
]);
});
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('nas_mikrotiks', function (Blueprint $table) {
$table->timestamp('next_health_check_at')->nullable()->after('last_checked_at');
$table->json('health_details')->nullable()->after('last_connection_error');
$table->index(['status', 'next_health_check_at'], 'nas_mikrotiks_health_due_index');
});
}
public function down(): void
{
Schema::table('nas_mikrotiks', function (Blueprint $table) {
$table->dropIndex('nas_mikrotiks_health_due_index');
$table->dropColumn(['next_health_check_at', 'health_details']);
});
}
};
@@ -0,0 +1,110 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('nas_package_profiles', function (Blueprint $table) {
$table->string('profile_code', 80)->nullable()->after('name');
$table->string('usage_mode', 30)->default('recurring_member')->after('service_type');
$table->string('validity_start', 30)->default('activation')->after('usage_mode');
$table->unsignedInteger('validity_value')->nullable()->after('validity_start');
$table->string('validity_unit', 20)->nullable()->after('validity_value');
$table->unsignedBigInteger('uptime_limit_seconds')->nullable()->after('validity_unit');
$table->unsignedBigInteger('data_limit_bytes')->nullable()->after('uptime_limit_seconds');
$table->unsignedInteger('grace_period_minutes')->default(0)->after('data_limit_bytes');
$table->json('service_parameters')->nullable()->after('grace_period_minutes');
$table->unique(['tenant_id', 'profile_code']);
});
Schema::create('nas_script_policies', function (Blueprint $table) {
$table->id();
$table->string('code', 100)->unique();
$table->string('name');
$table->string('service_type', 20);
$table->string('event', 30);
$table->unsignedInteger('current_version')->default(1);
$table->json('allowed_variables')->nullable();
$table->boolean('enabled')->default(true);
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['service_type', 'event', 'name']);
});
Schema::create('nas_script_policy_versions', function (Blueprint $table) {
$table->id();
$table->foreignId('nas_script_policy_id')->constrained()->cascadeOnDelete();
$table->unsignedInteger('version');
$table->longText('script_template');
$table->string('checksum', 64);
$table->text('change_notes')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['nas_script_policy_id', 'version']);
});
Schema::create('nas_package_profile_scripts', function (Blueprint $table) {
$table->id();
$table->foreignId('nas_package_profile_id')->constrained()->cascadeOnDelete();
$table->foreignId('nas_script_policy_id')->constrained()->restrictOnDelete();
$table->foreignId('nas_script_policy_version_id')->nullable()->constrained()->nullOnDelete();
$table->string('event', 30);
$table->boolean('enabled')->default(true);
$table->timestamps();
$table->unique(['nas_package_profile_id', 'event'], 'nas_package_profile_event_unique');
});
Schema::create('nas_package_profile_targets', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('nas_package_profile_id')->constrained()->cascadeOnDelete();
$table->foreignId('nas_mikrotik_id')->constrained()->cascadeOnDelete();
$table->string('backend_type', 30);
$table->string('external_profile_name');
$table->string('remote_id')->nullable();
$table->string('sync_status', 30)->default('not_deployed');
$table->json('desired_snapshot')->nullable();
$table->json('remote_snapshot')->nullable();
$table->string('deployed_checksum', 64)->nullable();
$table->timestamp('last_checked_at')->nullable();
$table->timestamp('last_synced_at')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
$table->unique(['nas_package_profile_id', 'nas_mikrotik_id'], 'nas_package_profile_target_unique');
$table->index(['tenant_id', 'sync_status']);
});
Schema::table('customer_nas_accounts', function (Blueprint $table) {
$table->foreignId('nas_package_profile_target_id')->nullable()->after('nas_package_profile_id')->constrained()->nullOnDelete();
$table->string('usage_mode', 30)->default('recurring_member')->after('service_type');
$table->timestamp('validity_started_at')->nullable()->after('status');
$table->timestamp('first_login_at')->nullable()->after('validity_started_at');
$table->timestamp('expires_at')->nullable()->after('first_login_at');
$table->unsignedBigInteger('used_uptime_seconds')->default(0)->after('expires_at');
$table->unsignedBigInteger('used_bytes')->default(0)->after('used_uptime_seconds');
$table->timestamp('last_reconciled_at')->nullable()->after('last_synced_at');
$table->json('remote_state')->nullable()->after('last_reconciled_at');
$table->index(['status', 'expires_at']);
});
}
public function down(): void
{
Schema::table('customer_nas_accounts', function (Blueprint $table) {
$table->dropConstrainedForeignId('nas_package_profile_target_id');
$table->dropColumn(['usage_mode', 'validity_started_at', 'first_login_at', 'expires_at', 'used_uptime_seconds', 'used_bytes', 'last_reconciled_at', 'remote_state']);
});
Schema::dropIfExists('nas_package_profile_targets');
Schema::dropIfExists('nas_package_profile_scripts');
Schema::dropIfExists('nas_script_policy_versions');
Schema::dropIfExists('nas_script_policies');
Schema::table('nas_package_profiles', function (Blueprint $table) {
$table->dropUnique(['tenant_id', 'profile_code']);
$table->dropColumn(['profile_code', 'usage_mode', 'validity_start', 'validity_value', 'validity_unit', 'uptime_limit_seconds', 'data_limit_bytes', 'grace_period_minutes', 'service_parameters']);
});
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('nas_package_profiles', function (Blueprint $table) {
$table->string('usage_mode', 30)->nullable()->default(null)->change();
$table->string('validity_start', 30)->nullable()->default(null)->change();
$table->unsignedInteger('grace_period_minutes')->nullable()->default(null)->change();
$table->boolean('mac_lock_required')->default(false)->after('grace_period_minutes');
$table->string('ip_pool')->nullable()->after('mac_lock_required');
$table->string('subnet_cidr', 64)->nullable()->after('ip_pool');
$table->string('gateway', 64)->nullable()->after('subnet_cidr');
$table->json('dns_servers')->nullable()->after('gateway');
$table->unsignedSmallInteger('vlan_id')->nullable()->after('dns_servers');
});
Schema::table('customer_nas_accounts', function (Blueprint $table) {
$table->string('mac_address', 17)->nullable()->after('username');
$table->string('assigned_ip', 64)->nullable()->after('mac_address');
$table->index(['nas_mikrotik_id', 'mac_address']);
});
}
public function down(): void
{
Schema::table('customer_nas_accounts', function (Blueprint $table) {
$table->dropIndex(['nas_mikrotik_id', 'mac_address']);
$table->dropColumn(['mac_address', 'assigned_ip']);
});
Schema::table('nas_package_profiles', function (Blueprint $table) {
$table->dropColumn(['mac_lock_required', 'ip_pool', 'subnet_cidr', 'gateway', 'dns_servers', 'vlan_id']);
$table->string('usage_mode', 30)->nullable(false)->default('recurring_member')->change();
$table->string('validity_start', 30)->nullable(false)->default('activation')->change();
$table->unsignedInteger('grace_period_minutes')->nullable(false)->default(0)->change();
});
}
};
@@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('radius_clusters', function (Blueprint $table) {
$table->id();
$table->string('code', 80)->unique();
$table->string('name');
$table->string('region', 80)->nullable();
$table->json('auth_endpoints')->nullable();
$table->json('accounting_endpoints')->nullable();
$table->string('provisioning_api_url')->nullable();
$table->text('provisioning_api_token')->nullable();
$table->string('status', 20)->default('planned');
$table->timestamps();
});
Schema::create('tenant_nas_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->unique()->constrained()->cascadeOnDelete();
$table->string('backend_mode', 30)->default('routeros_api');
$table->string('hybrid_radius_source', 20)->nullable();
$table->foreignId('radius_cluster_id')->nullable()->constrained('radius_clusters')->nullOnDelete();
$table->string('external_radius_host')->nullable();
$table->unsignedSmallInteger('external_auth_port')->default(1812);
$table->unsignedSmallInteger('external_accounting_port')->default(1813);
$table->unsignedSmallInteger('external_coa_port')->default(3799);
$table->text('external_radius_secret')->nullable();
$table->string('external_provisioning_api_url')->nullable();
$table->text('external_provisioning_api_token')->nullable();
$table->boolean('accounting_enabled')->default(true);
$table->boolean('coa_enabled')->default(true);
$table->string('health_status', 20)->default('not_configured');
$table->timestamp('last_checked_at')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
});
Schema::table('nas_mikrotiks', function (Blueprint $table) {
$table->string('backend_mode_override', 30)->nullable()->after('connection_type');
$table->string('nas_identifier')->nullable()->after('host');
$table->string('nas_ip_address', 64)->nullable()->after('nas_identifier');
$table->unsignedSmallInteger('radius_coa_port')->default(3799)->after('radius_accounting_port');
$table->index(['tenant_id', 'backend_mode_override']);
});
}
public function down(): void
{
Schema::table('nas_mikrotiks', function (Blueprint $table) {
$table->dropIndex(['tenant_id', 'backend_mode_override']);
$table->dropColumn(['backend_mode_override', 'nas_identifier', 'nas_ip_address', 'radius_coa_port']);
});
Schema::dropIfExists('tenant_nas_settings');
Schema::dropIfExists('radius_clusters');
}
};
@@ -0,0 +1,20 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('nas_mikrotiks', function (Blueprint $table) {
$table->string('radius_host')->nullable()->after('nas_ip_address');
});
}
public function down(): void
{
Schema::table('nas_mikrotiks', fn (Blueprint $table) => $table->dropColumn('radius_host'));
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('radius_clusters', function (Blueprint $table) {
$table->json('coa_endpoints')->nullable()->after('accounting_endpoints');
$table->string('healthcheck_url')->nullable()->after('provisioning_api_url');
$table->unsignedSmallInteger('request_timeout')->default(5)->after('healthcheck_url');
$table->unsignedSmallInteger('retry_attempts')->default(2)->after('request_timeout');
$table->text('notes')->nullable()->after('status');
$table->timestamp('last_checked_at')->nullable();
$table->unsignedInteger('last_latency_ms')->nullable();
$table->text('last_error')->nullable();
});
}
public function down(): void
{
Schema::table('radius_clusters', fn (Blueprint $table) => $table->dropColumn(['coa_endpoints','healthcheck_url','request_timeout','retry_attempts','notes','last_checked_at','last_latency_ms','last_error']));
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->string('customer_code')->nullable()->change();
$table->foreignId('sales_user_id')->nullable()->after('user_id')->constrained('users')->nullOnDelete();
$table->foreignId('topology_port_id')->nullable()->after('topology_id')->constrained('topology_ports')->nullOnDelete();
$table->index(['tenant_id','sales_user_id','status']);
});
Schema::table('user_tenants', fn (Blueprint $table) => $table->string('department', 50)->nullable()->after('role')->index());
DB::statement("CREATE UNIQUE INDEX topology_customer_active_port_unique ON topology_customer_connections (odp_port_id) WHERE odp_port_id IS NOT NULL AND status IN ('reserved','active')");
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS topology_customer_active_port_unique');
Schema::table('user_tenants', fn (Blueprint $table) => $table->dropColumn('department'));
Schema::table('customers', function (Blueprint $table) {
$table->dropIndex(['tenant_id','sales_user_id','status']);
$table->dropConstrainedForeignId('sales_user_id');
$table->dropConstrainedForeignId('topology_port_id');
$table->string('customer_code')->nullable(false)->change();
});
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
Schema::table('customers', fn (Blueprint $table) => $table->unsignedInteger('topology_port_number')->nullable()->after('topology_id'));
DB::statement('UPDATE customers c SET topology_port_number = p.port_number FROM topology_ports p WHERE c.topology_port_id = p.id');
Schema::table('customers', fn (Blueprint $table) => $table->dropConstrainedForeignId('topology_port_id'));
}
public function down(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->foreignId('topology_port_id')->nullable()->after('topology_id')->constrained('topology_ports')->nullOnDelete();
$table->dropColumn('topology_port_number');
});
}
};
@@ -0,0 +1,17 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement("DELETE FROM topology_ports p WHERE p.status = 'available'
AND NOT EXISTS (SELECT 1 FROM topology_customer_connections c WHERE c.odp_port_id = p.id)
AND NOT EXISTS (SELECT 1 FROM topology_links l WHERE l.source_port_id = p.id OR l.target_port_id = p.id)
AND NOT EXISTS (SELECT 1 FROM topology_splitters s WHERE s.input_port_id = p.id)");
}
public function down(): void {}
};
@@ -0,0 +1,21 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('customer_installation_workflows', function(Blueprint $t){$t->id();$t->foreignId('tenant_id')->constrained()->cascadeOnDelete();$t->string('name');$t->string('code',80);$t->text('description')->nullable();$t->boolean('is_default')->default(false);$t->string('status',20)->default('draft');$t->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();$t->timestamps();$t->unique(['tenant_id','code']);$t->index(['tenant_id','is_default','status']);});
Schema::create('customer_installation_steps', function(Blueprint $t){$t->id();$t->foreignId('workflow_id')->constrained('customer_installation_workflows')->cascadeOnDelete();$t->string('name');$t->string('code',80);$t->string('step_type',30)->default('custom');$t->unsignedInteger('sort_order');$t->boolean('is_initial')->default(false);$t->boolean('is_final')->default(false);$t->boolean('requires_ticket')->default(false);$t->unsignedBigInteger('ticket_type_id')->nullable();$t->string('ticket_priority',20)->default('medium');$t->boolean('requires_approval')->default(false);$t->timestamps();$t->unique(['workflow_id','code']);$t->unique(['workflow_id','sort_order']);});
Schema::create('customer_installation_step_fields', function(Blueprint $t){$t->id();$t->foreignId('step_id')->constrained('customer_installation_steps')->cascadeOnDelete();$t->string('field_code',100);$t->boolean('is_visible')->default(true);$t->boolean('is_required')->default(false);$t->boolean('is_readonly')->default(false);$t->unsignedInteger('sort_order')->default(0);$t->timestamps();$t->unique(['step_id','field_code']);});
Schema::create('customer_installation_transitions', function(Blueprint $t){$t->id();$t->foreignId('workflow_id')->constrained('customer_installation_workflows')->cascadeOnDelete();$t->foreignId('from_step_id')->constrained('customer_installation_steps')->cascadeOnDelete();$t->foreignId('to_step_id')->constrained('customer_installation_steps')->cascadeOnDelete();$t->string('name');$t->string('code',100);$t->boolean('requires_approval')->default(false);$t->boolean('creates_ticket')->default(false);$t->boolean('closes_ticket')->default(false);$t->timestamps();$t->unique(['workflow_id','code']);});
Schema::create('customer_installation_instances', function(Blueprint $t){$t->id();$t->foreignId('tenant_id')->constrained()->cascadeOnDelete();$t->foreignId('customer_id')->unique()->constrained()->cascadeOnDelete();$t->foreignId('workflow_id')->constrained('customer_installation_workflows')->restrictOnDelete();$t->foreignId('current_step_id')->constrained('customer_installation_steps')->restrictOnDelete();$t->string('status',20)->default('in_progress');$t->timestamp('started_at')->nullable();$t->timestamp('completed_at')->nullable();$t->timestamps();$t->index(['tenant_id','current_step_id','status']);});
Schema::create('customer_installation_histories', function(Blueprint $t){$t->id();$t->foreignId('installation_instance_id')->constrained('customer_installation_instances')->cascadeOnDelete();$t->foreignId('from_step_id')->nullable()->constrained('customer_installation_steps')->nullOnDelete();$t->foreignId('to_step_id')->constrained('customer_installation_steps')->restrictOnDelete();$t->foreignId('transition_id')->nullable()->constrained('customer_installation_transitions')->nullOnDelete();$t->foreignId('performed_by')->nullable()->constrained('users')->nullOnDelete();$t->unsignedBigInteger('ticket_id')->nullable();$t->text('notes')->nullable();$t->json('form_snapshot')->nullable();$t->timestamps();});
Schema::create('menu_group_workflow_permissions', function(Blueprint $t){$t->id();$t->foreignId('menu_group_id')->constrained()->cascadeOnDelete();$t->foreignId('workflow_id')->constrained('customer_installation_workflows')->cascadeOnDelete();$t->foreignId('step_id')->constrained('customer_installation_steps')->cascadeOnDelete();$t->boolean('can_view')->default(false);$t->boolean('can_fill')->default(false);$t->boolean('can_submit')->default(false);$t->boolean('can_approve')->default(false);$t->boolean('can_reject')->default(false);$t->boolean('can_assign')->default(false);$t->timestamps();$t->unique(['menu_group_id','step_id']);});
Schema::table('tickets', function(Blueprint $t){$t->foreignId('installation_instance_id')->nullable()->constrained('customer_installation_instances')->nullOnDelete();$t->foreignId('workflow_step_id')->nullable()->constrained('customer_installation_steps')->nullOnDelete();$t->index(['installation_instance_id','workflow_step_id']);});
}
public function down(): void {Schema::table('tickets',function(Blueprint $t){$t->dropConstrainedForeignId('installation_instance_id');$t->dropConstrainedForeignId('workflow_step_id');});Schema::dropIfExists('menu_group_workflow_permissions');Schema::dropIfExists('customer_installation_histories');Schema::dropIfExists('customer_installation_instances');Schema::dropIfExists('customer_installation_transitions');Schema::dropIfExists('customer_installation_step_fields');Schema::dropIfExists('customer_installation_steps');Schema::dropIfExists('customer_installation_workflows');}
};
@@ -0,0 +1,3 @@
<?php
use Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;
return new class extends Migration{public function up():void{Schema::create('tenant_installation_settings',function(Blueprint $t){$t->id();$t->foreignId('tenant_id')->unique()->constrained()->cascadeOnDelete();$t->string('activation_mode',30)->default('list_order');$t->boolean('ktp_ocr_enabled')->default(false);$t->string('ocr_provider',30)->nullable();$t->timestamps();});Schema::table('menu_group_menus',fn(Blueprint $t)=>$t->boolean('can_activate')->default(false)->after('can_approve'));Schema::table('tickets',function(Blueprint $t){$t->dropConstrainedForeignId('installation_instance_id');$t->dropConstrainedForeignId('workflow_step_id');});Schema::dropIfExists('menu_group_workflow_permissions');Schema::dropIfExists('customer_installation_histories');Schema::dropIfExists('customer_installation_instances');Schema::dropIfExists('customer_installation_transitions');Schema::dropIfExists('customer_installation_step_fields');Schema::dropIfExists('customer_installation_steps');Schema::dropIfExists('customer_installation_workflows');}public function down():void{Schema::dropIfExists('tenant_installation_settings');Schema::table('menu_group_menus',fn(Blueprint $t)=>$t->dropColumn('can_activate'));}};
@@ -0,0 +1,20 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tenant_installation_settings', function (Blueprint $table) {
$table->text('ocr_api_key')->nullable()->after('ocr_provider');
});
}
public function down(): void
{
Schema::table('tenant_installation_settings', fn (Blueprint $table) => $table->dropColumn('ocr_api_key'));
}
};
@@ -0,0 +1,18 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('customer_nas_accounts', fn (Blueprint $table) => $table->string('serial_number', 120)->nullable()->after('mac_address'));
}
public function down(): void
{
Schema::table('customer_nas_accounts', fn (Blueprint $table) => $table->dropColumn('serial_number'));
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->dropForeign(['customer_id']);
});
Schema::table('invoices', function (Blueprint $table) {
$table->unsignedBigInteger('customer_id')->nullable()->change();
$table->foreign('customer_id')->references('id')->on('customers')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->dropForeign(['customer_id']);
});
Schema::table('invoices', function (Blueprint $table) {
$table->unsignedBigInteger('customer_id')->nullable(false)->change();
$table->foreign('customer_id')->references('id')->on('customers')->restrictOnDelete();
});
}
};
@@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->string('source', 30)->default('manual')->after('customer_id')->index();
$table->string('installation_step', 30)->nullable()->after('source');
$table->foreignId('verified_by')->nullable()->after('resolved_at')->constrained('users')->nullOnDelete();
$table->timestamp('verified_at')->nullable()->after('verified_by');
$table->text('verification_notes')->nullable()->after('verified_at');
});
Schema::create('ticket_attachments', function (Blueprint $table) {
$table->id(); $table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->foreignId('stored_file_id')->constrained('stored_files')->cascadeOnDelete();
$table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
$table->string('stage', 30); $table->text('notes')->nullable(); $table->timestamps();
$table->unique(['ticket_id','stored_file_id','stage']);
});
if (! Schema::hasColumn('ticket_materials','technician_consumable_id')) Schema::table('ticket_materials', function (Blueprint $table) {
$table->foreignId('technician_consumable_id')->nullable()->after('technician_material_id')->constrained('technician_consumables')->restrictOnDelete();
});
Schema::create('warehouse_items', function (Blueprint $table) {
$table->id(); $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('sku'); $table->string('name'); $table->string('category')->nullable();
$table->enum('tracking_type',['serialized','bulk']); $table->string('unit',30)->default('unit');
$table->decimal('minimum_stock',15,2)->default(0); $table->boolean('is_active')->default(true); $table->timestamps();
$table->unique(['tenant_id','sku']);
});
Schema::create('item_barcodes', function (Blueprint $table) {
$table->id(); $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('warehouse_item_id')->constrained()->cascadeOnDelete(); $table->string('barcode')->unique();
$table->string('serial_number')->nullable(); $table->decimal('initial_qty',15,2)->default(1);
$table->decimal('remaining_qty',15,2)->default(1); $table->string('status',30)->default('available'); $table->timestamps();
});
Schema::create('item_movements', function (Blueprint $table) {
$table->id(); $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('warehouse_item_id')->constrained()->restrictOnDelete();
$table->foreignId('item_barcode_id')->nullable()->constrained()->restrictOnDelete();
$table->string('movement_type',30); $table->decimal('qty',15,2); $table->string('reference_type')->nullable();
$table->unsignedBigInteger('reference_id')->nullable(); $table->foreignId('from_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('to_user_id')->nullable()->constrained('users')->nullOnDelete(); $table->text('notes')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps();
});
Schema::create('item_stock_logs', function (Blueprint $table) {
$table->id(); $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('warehouse_item_id')->constrained()->restrictOnDelete();
$table->foreignId('item_movement_id')->nullable()->constrained()->nullOnDelete();
$table->decimal('qty_before',15,2); $table->decimal('qty_change',15,2); $table->decimal('qty_after',15,2); $table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('item_stock_logs'); Schema::dropIfExists('item_movements'); Schema::dropIfExists('item_barcodes'); Schema::dropIfExists('warehouse_items');
if (Schema::hasColumn('ticket_materials','technician_consumable_id')) Schema::table('ticket_materials', fn(Blueprint $t)=>$t->dropConstrainedForeignId('technician_consumable_id'));
Schema::dropIfExists('ticket_attachments');
Schema::table('tickets', function(Blueprint $t){$t->dropConstrainedForeignId('verified_by');$t->dropColumn(['source','installation_step','verified_at','verification_notes']);});
}
};
@@ -0,0 +1,75 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('item_codes', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('code', 80);
$table->string('name');
$table->string('category', 100)->nullable();
$table->enum('tracking_type', ['serialized', 'quantity'])->default('serialized');
$table->boolean('need_serial_number')->default(false);
$table->string('unit', 30)->default('pcs');
$table->decimal('minimum_stock', 15, 2)->default(0);
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['tenant_id', 'code']);
});
Schema::table('warehouse_items', function (Blueprint $table) {
$table->foreignId('item_code_id')->nullable()->after('tenant_id')->constrained('item_codes')->restrictOnDelete();
});
Schema::table('item_barcodes', function (Blueprint $table) {
$table->string('tracking_type', 20)->nullable()->after('barcode');
$table->string('unit', 30)->nullable()->after('tracking_type');
});
Schema::table('item_stock_logs', function (Blueprint $table) {
$table->foreignId('item_barcode_id')->nullable()->after('warehouse_item_id')->constrained()->nullOnDelete();
$table->decimal('barcode_qty_before', 15, 2)->nullable()->after('qty_after');
$table->decimal('barcode_qty_change', 15, 2)->nullable()->after('barcode_qty_before');
$table->decimal('barcode_qty_after', 15, 2)->nullable()->after('barcode_qty_change');
});
foreach (DB::table('warehouse_items')->orderBy('id')->get() as $item) {
$codeId = DB::table('item_codes')->insertGetId([
'tenant_id' => $item->tenant_id,
'code' => $item->sku,
'name' => $item->name,
'category' => $item->category,
'tracking_type' => $item->tracking_type === 'serialized' ? 'serialized' : 'quantity',
'need_serial_number' => false,
'unit' => $item->unit,
'minimum_stock' => $item->minimum_stock,
'is_active' => $item->is_active,
'created_at' => $item->created_at,
'updated_at' => $item->updated_at,
]);
DB::table('warehouse_items')->where('id', $item->id)->update(['item_code_id' => $codeId]);
DB::table('item_barcodes')->where('warehouse_item_id', $item->id)->update([
'tracking_type' => $item->tracking_type === 'serialized' ? 'serialized' : 'quantity',
'unit' => $item->unit,
]);
}
}
public function down(): void
{
Schema::table('item_stock_logs', function (Blueprint $table) {
$table->dropConstrainedForeignId('item_barcode_id');
$table->dropColumn(['barcode_qty_before', 'barcode_qty_change', 'barcode_qty_after']);
});
Schema::table('item_barcodes', fn (Blueprint $table) => $table->dropColumn(['tracking_type', 'unit']));
Schema::table('warehouse_items', fn (Blueprint $table) => $table->dropConstrainedForeignId('item_code_id'));
Schema::dropIfExists('item_codes');
}
};
@@ -0,0 +1,20 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('warehouse_items', fn (Blueprint $table) => $table->dropColumn('category'));
Schema::table('item_codes', fn (Blueprint $table) => $table->dropColumn('category'));
}
public function down(): void
{
Schema::table('item_codes', fn (Blueprint $table) => $table->string('category', 100)->nullable());
Schema::table('warehouse_items', fn (Blueprint $table) => $table->string('category', 100)->nullable());
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('warehouses', function (Blueprint $table) {
$table->id(); $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('code', 50); $table->string('name'); $table->text('address')->nullable();
$table->boolean('is_active')->default(true); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps();
$table->unique(['tenant_id','code']);
});
Schema::table('warehouse_items', function (Blueprint $table) {
$table->foreignId('warehouse_id')->nullable()->after('tenant_id')->constrained('warehouses')->restrictOnDelete();
$table->string('item_number',100)->nullable()->after('item_code_id');
$table->string('status',20)->default('draft')->after('item_number')->index();
$table->decimal('received_qty',15,2)->default(0)->after('minimum_stock');
$table->date('received_at')->nullable()->after('received_qty');
$table->foreignId('posted_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('posted_at')->nullable(); $table->text('notes')->nullable();
$table->unique(['tenant_id','item_number']);
});
Schema::table('item_barcodes', function (Blueprint $table) {
$table->unsignedInteger('package_number')->nullable()->after('warehouse_item_id');
$table->unique(['tenant_id','serial_number']);
});
foreach (DB::table('tenants')->select('id')->get() as $tenant) {
$warehouseId=DB::table('warehouses')->insertGetId(['tenant_id'=>$tenant->id,'code'=>'MAIN','name'=>'Gudang Utama','is_active'=>true,'created_at'=>now(),'updated_at'=>now()]);
foreach(DB::table('warehouse_items')->where('tenant_id',$tenant->id)->orderBy('id')->get() as $item){
$date=\Carbon\Carbon::parse($item->created_at)->format('dmy');$base=$item->sku.'-'.$date;$number=$base.'-'.str_pad((string)$item->id,3,'0',STR_PAD_LEFT);
DB::table('warehouse_items')->where('id',$item->id)->update(['warehouse_id'=>$warehouseId,'item_number'=>$number,'status'=>DB::table('item_barcodes')->where('warehouse_item_id',$item->id)->sum('remaining_qty')>0?'active':'depleted','received_qty'=>DB::table('item_barcodes')->where('warehouse_item_id',$item->id)->sum('initial_qty'),'received_at'=>\Carbon\Carbon::parse($item->created_at)->toDateString(),'posted_at'=>$item->created_at]);
}
}
}
public function down(): void
{
Schema::table('item_barcodes',function(Blueprint $t){$t->dropUnique(['tenant_id','serial_number']);$t->dropColumn('package_number');});
Schema::table('warehouse_items',function(Blueprint $t){$t->dropConstrainedForeignId('warehouse_id');$t->dropConstrainedForeignId('posted_by');$t->dropUnique(['tenant_id','item_number']);$t->dropColumn(['item_number','status','received_qty','received_at','posted_at','notes']);});
Schema::dropIfExists('warehouses');
}
};
+26 -14
View File
@@ -13,6 +13,14 @@ class MenuListSeeder extends Seeder
*/
public function run(): void
{
MenuList::where('slug', 'customers-installation-workflows')->delete();
$warehouse = MenuList::updateOrCreate(['slug'=>'warehouse'],['parent_id'=>null,'name'=>'Gudang','url'=>null,'icon'=>'cil-storage','component'=>null,'sort_order'=>8,'is_active'=>true]);
MenuList::updateOrCreate(['slug'=>'warehouse-pending'],['parent_id'=>$warehouse->id,'name'=>'Barang Pending','url'=>'/warehouse/pending','icon'=>'cil-clock','component'=>null,'sort_order'=>1,'is_active'=>true]);
MenuList::updateOrCreate(['slug'=>'warehouse-items'],['parent_id'=>$warehouse->id,'name'=>'Daftar Barang','url'=>'/warehouse/items','icon'=>'cil-list','component'=>null,'sort_order'=>2,'is_active'=>true]);
MenuList::updateOrCreate(['slug'=>'warehouse-depleted'],['parent_id'=>$warehouse->id,'name'=>'Barang Habis','url'=>'/warehouse/depleted','icon'=>'cil-inbox','component'=>null,'sort_order'=>3,'is_active'=>true]);
MenuList::updateOrCreate(['slug'=>'warehouse-technician-inventory'],['parent_id'=>$warehouse->id,'name'=>'Peralatan Teknisi','url'=>'/warehouse/technician-inventory','icon'=>'cil-briefcase','component'=>null,'sort_order'=>4,'is_active'=>true]);
MenuList::updateOrCreate(['slug'=>'warehouse-locations'],['parent_id'=>$warehouse->id,'name'=>'Daftar Gudang','url'=>'/warehouse/locations','icon'=>'cil-building','component'=>null,'sort_order'=>5,'is_active'=>true]);
MenuList::updateOrCreate(['slug'=>'warehouse-codes'],['parent_id'=>$warehouse->id,'name'=>'Kode Barang','url'=>'/warehouse/item-codes','icon'=>'cil-barcode','component'=>null,'sort_order'=>6,'is_active'=>true]);
$users = MenuList::updateOrCreate(
['slug' => 'users'],
[
@@ -21,7 +29,7 @@ class MenuListSeeder extends Seeder
'url' => null,
'icon' => 'cil-user',
'component' => null,
'sort_order' => 1,
'sort_order' => 10,
'is_active' => true,
]
);
@@ -56,14 +64,16 @@ class MenuListSeeder extends Seeder
['slug' => 'tenants'],
[
'parent_id' => null,
'name' => 'Tenant',
'name' => 'Daftar Tenant',
'url' => '/tenants',
'icon' => 'cil-layers',
'component' => null,
'sort_order' => 2,
'sort_order' => 11,
'is_active' => true,
]
);
MenuList::where('slug', 'tenant-installation-settings')->delete();
MenuList::where('slug', 'tenants-list')->update(['is_active' => false]);
$ticketing = MenuList::updateOrCreate(
['slug' => 'ticketing'],
@@ -73,7 +83,7 @@ class MenuListSeeder extends Seeder
'url' => null,
'icon' => 'cil-description',
'component' => null,
'sort_order' => 3,
'sort_order' => 6,
'is_active' => true,
]
);
@@ -164,7 +174,7 @@ class MenuListSeeder extends Seeder
'url' => null,
'icon' => 'cil-location-pin',
'component' => null,
'sort_order' => 4,
'sort_order' => 13,
'is_active' => true,
]
);
@@ -191,7 +201,7 @@ class MenuListSeeder extends Seeder
$accessApplications = MenuList::updateOrCreate(['slug' => 'role-applications'], [
'parent_id' => null, 'name' => 'Permintaan Akses', 'url' => null,
'icon' => 'cil-user-follow', 'component' => null, 'sort_order' => 83,
'icon' => 'cil-user-follow', 'component' => null, 'sort_order' => 12,
]);
foreach ([
['slug' => 'role-applications-tenants', 'name' => 'Pengajuan Tenant', 'url' => '/access-applications/tenants', 'sort_order' => 1],
@@ -227,7 +237,7 @@ class MenuListSeeder extends Seeder
['slug' => 'payment-gateway'],
[
'parent_id' => null, 'name' => 'Payment Gateway', 'url' => null,
'icon' => 'cil-credit-card', 'component' => null, 'sort_order' => 80,
'icon' => 'cil-credit-card', 'component' => null, 'sort_order' => 9,
]
);
foreach ([
@@ -242,14 +252,14 @@ class MenuListSeeder extends Seeder
MenuList::updateOrCreate(['slug' => 'wallet'], [
'parent_id' => null, 'name' => 'Saldo Deposit', 'url' => '/deposit-balance',
'icon' => 'cil-wallet', 'component' => null, 'sort_order' => 81,
'icon' => 'cil-wallet', 'component' => null, 'sort_order' => 8,
]);
$voucherHotspot = MenuList::updateOrCreate(
['slug' => 'voucher-hotspot'],
[
'parent_id' => null, 'name' => 'Voucher Hotspot', 'url' => null,
'icon' => 'cil-barcode', 'component' => null, 'sort_order' => 82,
'icon' => 'cil-barcode', 'component' => null, 'sort_order' => 3,
]
);
foreach ([
@@ -271,7 +281,7 @@ class MenuListSeeder extends Seeder
'url' => null,
'icon' => 'cil-settings',
'component' => null,
'sort_order' => 5,
'sort_order' => 4,
'is_active' => true,
]
);
@@ -281,6 +291,8 @@ class MenuListSeeder extends Seeder
['slug' => 'nas-package-profiles', 'name' => 'Profile Paket', 'url' => '/nas/package-profiles', 'icon' => 'cil-speedometer', 'sort_order' => 2],
['slug' => 'nas-olt', 'name' => 'OLT', 'url' => '/nas/olt', 'icon' => 'cil-layers', 'sort_order' => 3],
['slug' => 'nas-webfig', 'name' => 'Webfig', 'url' => '/nas/webfig', 'icon' => 'cil-laptop', 'sort_order' => 4],
['slug' => 'nas-script-policies', 'name' => 'Script Policy', 'url' => '/nas/script-policies', 'icon' => 'cil-code', 'sort_order' => 5],
['slug' => 'nas-radius-infrastructure', 'name' => 'Infrastruktur RADIUS', 'url' => '/nas/radius-infrastructure', 'icon' => 'cil-storage', 'sort_order' => 6],
] as $item) {
MenuList::updateOrCreate(
['slug' => $item['slug']],
@@ -304,7 +316,7 @@ class MenuListSeeder extends Seeder
'url' => null,
'icon' => 'cil-people',
'component' => null,
'sort_order' => 6,
'sort_order' => 1,
'is_active' => true,
]
);
@@ -338,7 +350,7 @@ class MenuListSeeder extends Seeder
'url' => null,
'icon' => 'cil-description',
'component' => null,
'sort_order' => 7,
'sort_order' => 2,
'is_active' => true,
]
);
@@ -371,7 +383,7 @@ class MenuListSeeder extends Seeder
'url' => null,
'icon' => 'cil-layers',
'component' => null,
'sort_order' => 8,
'sort_order' => 5,
'is_active' => true,
]
);
@@ -405,7 +417,7 @@ class MenuListSeeder extends Seeder
'url' => null,
'icon' => 'cil-bell',
'component' => null,
'sort_order' => 9,
'sort_order' => 7,
'is_active' => true,
]
);
+90 -2
View File
@@ -12,6 +12,10 @@ use App\Http\Controllers\Menu\MenuController;
use App\Http\Controllers\Menu\MenuGroupController;
use App\Http\Controllers\Menu\MenuListController;
use App\Http\Controllers\Nas\NasResourceController;
use App\Http\Controllers\Nas\PackageProfileDeploymentController;
use App\Http\Controllers\Nas\ScriptPolicyController;
use App\Http\Controllers\Nas\TenantNasSettingController;
use App\Http\Controllers\Nas\RadiusClusterController;
use App\Http\Controllers\Notification\NotificationController;
use App\Http\Controllers\Notification\NotificationWebhookController;
use App\Http\Controllers\Payment\PaymentGatewayController;
@@ -26,6 +30,9 @@ use App\Http\Controllers\User\UserController;
use App\Http\Controllers\Voucher\VoucherController;
use App\Http\Controllers\Wallet\WalletController;
use App\Http\Controllers\Wilayah\WilayahController;
use App\Http\Controllers\Warehouse\WarehouseController;
use App\Http\Controllers\Warehouse\WarehouseBatchController;
use App\Http\Controllers\Warehouse\WarehouseLocationController;
use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login']);
@@ -135,6 +142,33 @@ Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () {
Route::delete('customers/{customer}/notification-contacts/{contact}', [NotificationController::class, 'destroyContact'])
->middleware('menu.permission:customers-active,update')->whereNumber('customer')->whereNumber('contact')->name('customers.notification-contacts.destroy');
Route::post('nas/mikrotiks/{id}/test-connection', [NasResourceController::class, 'testConnection'])
->defaults('nas_resource', 'mikrotik')
->middleware('menu.permission:nas-mikrotik,update')
->whereNumber('id')
->name('nas.mikrotiks.test-connection');
Route::get('nas/backend-setting', [TenantNasSettingController::class, 'show'])
->middleware('menu.permission:nas-mikrotik,view')->name('nas.backend-setting.show');
Route::put('nas/backend-setting', [TenantNasSettingController::class, 'update'])
->middleware('menu.permission:nas-mikrotik,update')->name('nas.backend-setting.update');
Route::get('nas/package-profiles/{profile}/targets', [PackageProfileDeploymentController::class, 'index'])
->middleware('menu.permission:nas-package-profiles,view')->name('nas.package-profiles.targets.index');
Route::get('nas/package-profile-script-options', [PackageProfileDeploymentController::class, 'scriptOptions'])
->middleware('menu.permission:nas-package-profiles,view')->name('nas.package-profiles.script-options');
Route::post('nas/package-profiles/{profile}/targets', [PackageProfileDeploymentController::class, 'store'])
->middleware('menu.permission:nas-package-profiles,update')->name('nas.package-profiles.targets.store');
Route::post('nas/package-profile-targets/{target}/sync', [PackageProfileDeploymentController::class, 'sync'])
->middleware('menu.permission:nas-package-profiles,update')->name('nas.package-profile-targets.sync');
Route::get('nas/script-policies', [ScriptPolicyController::class, 'index'])->name('nas.script-policies.index');
Route::post('nas/script-policies', [ScriptPolicyController::class, 'store'])->name('nas.script-policies.store');
Route::get('nas/script-policies/{policy}', [ScriptPolicyController::class, 'show'])->name('nas.script-policies.show');
Route::put('nas/script-policies/{policy}', [ScriptPolicyController::class, 'update'])->name('nas.script-policies.update');
Route::apiResource('nas/radius-clusters', RadiusClusterController::class)
->parameters(['radius-clusters' => 'radiusCluster'])
->middleware('access.level:master_admin');
foreach ([
'system' => ['channel' => 'system', 'menu' => 'notifications-system'],
'whatsapp-official' => ['channel' => 'whatsapp_official', 'menu' => 'notifications-wa-official'],
@@ -253,6 +287,10 @@ Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () {
Route::get('customers/options', [CustomerController::class, 'options'])
->name('customers.options')
->middleware('menu.permission:customers-orders,view');
Route::get('customers/options/odp/{node}/ports', [CustomerController::class, 'odpPorts'])
->name('customers.options.odp-ports')->whereNumber('node')
->middleware('menu.permission:customers-orders,view');
foreach ([
'orders' => 'customers-orders',
@@ -275,6 +313,9 @@ Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () {
Route::post('customers', [CustomerController::class, 'store'])
->name('customers.store')
->middleware('menu.permission:customers-orders,create');
Route::post('customers/ocr-ktp', [CustomerController::class, 'ocrKtp'])
->name('customers.ocr-ktp')
->middleware('menu.permission:customers-orders,create');
Route::put('customers/{id}', [CustomerController::class, 'update'])
->name('customers.update')
->whereNumber('id')
@@ -286,7 +327,15 @@ Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () {
Route::post('customers/{id}/activate', [CustomerController::class, 'activate'])
->name('customers.activate')
->whereNumber('id')
->middleware('menu.permission:customers-orders,update');
->middleware('menu.permission:customers-orders,activate');
Route::put('customers/{id}/registration', [CustomerController::class, 'updateRegistration'])
->name('customers.registration.update')
->whereNumber('id')
->middleware('menu.permission:customers-orders,create');
Route::put('customers/{id}/activation-preparation', [CustomerController::class, 'prepareActivation'])
->name('customers.activation-preparation')
->whereNumber('id')
->middleware('menu.permission:customers-orders,activate');
Route::post('customers/{id}/deactivate', [CustomerController::class, 'deactivate'])
->name('customers.deactivate')
->whereNumber('id')
@@ -295,6 +344,10 @@ Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () {
->name('customers.destroy')
->whereNumber('id')
->middleware('menu.permission:customers-inactive,delete');
Route::delete('customers/{id}/cancel-order', [CustomerController::class, 'cancelOrder'])
->name('customers.cancel-order')
->whereNumber('id')
->middleware('menu.permission:customers-orders,delete');
Route::post('customers/{id}/restore', [CustomerController::class, 'restore'])
->name('customers.restore')
->whereNumber('id')
@@ -339,6 +392,11 @@ Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () {
});
}
Route::get('tickets/options', [TicketController::class, 'options'])->middleware('menu.permission:ticketing-ticket,view');
Route::post('tickets/{ticket}/materials', [TicketController::class, 'storeMaterial'])->middleware('menu.permission:ticketing-ticket,update');
Route::get('tickets/{ticket}/material-options', [TicketController::class, 'materialOptions'])->middleware('menu.permission:ticketing-ticket,update');
Route::get('tickets/{ticket}/installation-ports/{node}', [TicketController::class, 'installationPorts'])->middleware('menu.permission:ticketing-ticket,update');
Route::delete('tickets/{ticket}/materials/{material}', [TicketController::class, 'destroyMaterial'])->middleware('menu.permission:ticketing-ticket,update');
Route::apiResource('tickets', TicketController::class)
->middleware('menu.resource:ticketing-ticket');
@@ -377,6 +435,8 @@ Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () {
Route::post('/close', 'close')
->middleware('menu.permission:ticketing-ticket,update');
Route::post('/verify', 'verify')->middleware('menu.permission:ticketing-ticket,approve');
Route::post('/installation-step', 'installationStep')->middleware('menu.permission:ticketing-ticket,update');
Route::post('/cancel', 'cancel')
->middleware('menu.permission:ticketing-ticket,delete');
@@ -399,6 +459,31 @@ Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () {
Route::get('/history/{barcode_id}', 'history');
});
Route::middleware('menu.resource:warehouse-items')->group(function(){
Route::get('warehouse/items',[WarehouseBatchController::class,'index']);
Route::get('warehouse/options',[WarehouseController::class,'options']);
Route::post('warehouse/barcodes/{barcode}/issue',[WarehouseController::class,'issue']);
Route::get('warehouse/movements',[WarehouseController::class,'movements']);
});
Route::middleware('menu.resource:warehouse-pending')->group(function(){
Route::get('warehouse/pending',[WarehouseBatchController::class,'index']);
Route::post('warehouse/pending',[WarehouseBatchController::class,'store']);
Route::put('warehouse/pending/{item}/serials',[WarehouseBatchController::class,'updateSerials']);
Route::post('warehouse/pending/{item}/post',[WarehouseBatchController::class,'post']);
Route::delete('warehouse/pending/{item}',[WarehouseBatchController::class,'destroy']);
});
Route::get('warehouse/depleted',[WarehouseBatchController::class,'index'])->middleware('menu.resource:warehouse-depleted');
Route::get('warehouse/batches/{item}',[WarehouseBatchController::class,'show'])->middleware('menu.resource:warehouse-items');
Route::get('warehouse/batches/{item}/barcodes/{barcode}/movements',[WarehouseBatchController::class,'barcodeMovements'])->middleware('menu.resource:warehouse-items');
Route::apiResource('warehouse/locations',WarehouseLocationController::class)->parameters(['locations'=>'warehouse'])->except('show')->middleware('menu.resource:warehouse-locations');
Route::middleware('menu.resource:warehouse-codes')->group(function(){
Route::get('warehouse/item-codes',[WarehouseController::class,'codeIndex']);
Route::post('warehouse/item-codes',[WarehouseController::class,'codeStore']);
Route::put('warehouse/item-codes/{code}',[WarehouseController::class,'codeUpdate']);
Route::delete('warehouse/item-codes/{code}',[WarehouseController::class,'codeDestroy']);
});
Route::get('warehouse/technician-inventory',[WarehouseController::class,'technicianInventory'])->middleware('menu.resource:warehouse-technician-inventory');
Route::post('warehouse/technician-inventory/return',[WarehouseController::class,'returnFromTechnician'])->middleware('menu.resource:warehouse-technician-inventory');
Route::prefix('audit-logs')
->middleware('access.level:tenant_owner')
@@ -481,7 +566,7 @@ Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () {
]);
Route::apiResource('tenants', TenantController::class)
->only(['index', 'show'])
->middleware('access.level:tenant_owner');
->middleware('menu.resource:tenants');
Route::apiResource('tenants', TenantController::class)
->except(['index', 'show'])
->middleware('access.level:master_admin');
@@ -504,6 +589,9 @@ Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () {
Route::put('/menus', 'syncMenus');
});
Route::get('tenants/{tenant}/installation-setting', [TenantController::class,'installationSetting'])->middleware('menu.permission:tenants,view');
Route::put('tenants/{tenant}/installation-setting', [TenantController::class,'updateInstallationSetting'])->middleware('menu.permission:tenants,update');
Route::prefix('users/{user}')
->middleware([
'access.level:tenant_owner',
+11
View File
@@ -19,3 +19,14 @@ Schedule::command('notifications:prune')
->timezone(config('app.timezone'))
->withoutOverlapping()
->onOneServer();
Schedule::command('nas:check-mikrotiks')
->everyMinute()
->withoutOverlapping()
->onOneServer();
Schedule::call(function () {
\App\Models\CustomerNasAccount::query()->whereIn('status', ['active','disabled'])
->where(fn ($q) => $q->whereNull('last_reconciled_at')->orWhere('last_reconciled_at','<=',now()->subMinutes(5)))
->select('id')->chunkById(200, fn ($rows) => $rows->each(fn ($row) => \App\Jobs\ReconcileCustomerNasAccount::dispatch($row->id)));
})->everyMinute()->name('nas-reconcile-accounts')->withoutOverlapping()->onOneServer();
+181
View File
@@ -29,31 +29,61 @@ return array(
'AWS\\CRT\\NativeResource' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/NativeResource.php',
'AWS\\CRT\\OptionValue' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Options.php',
'AWS\\CRT\\Options' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Options.php',
'App\\Console\\Commands\\CheckMikrotikConnections' => $baseDir . '/app/Console/Commands/CheckMikrotikConnections.php',
'App\\Console\\Commands\\ProcessBillingSchedules' => $baseDir . '/app/Console/Commands/ProcessBillingSchedules.php',
'App\\Console\\Commands\\PruneNotifications' => $baseDir . '/app/Console/Commands/PruneNotifications.php',
'App\\Enums\\TicketStatus' => $baseDir . '/app/Enums/TicketStatus.php',
'App\\Enums\\UserAccessLevel' => $baseDir . '/app/Enums/UserAccessLevel.php',
'App\\Http\\Controllers\\Access\\RoleApplicationController' => $baseDir . '/app/Http/Controllers/Access/RoleApplicationController.php',
'App\\Http\\Controllers\\ApiController' => $baseDir . '/app/Http/Controllers/ApiController.php',
'App\\Http\\Controllers\\Audit\\AuditLogController' => $baseDir . '/app/Http/Controllers/Audit/AuditLogController.php',
'App\\Http\\Controllers\\AuthController' => $baseDir . '/app/Http/Controllers/AuthController.php',
'App\\Http\\Controllers\\Billing\\BillingProfileController' => $baseDir . '/app/Http/Controllers/Billing/BillingProfileController.php',
'App\\Http\\Controllers\\Billing\\InvoiceController' => $baseDir . '/app/Http/Controllers/Billing/InvoiceController.php',
'App\\Http\\Controllers\\Controller' => $baseDir . '/app/Http/Controllers/Controller.php',
'App\\Http\\Controllers\\Customer\\CustomerController' => $baseDir . '/app/Http/Controllers/Customer/CustomerController.php',
'App\\Http\\Controllers\\File\\StoredFileController' => $baseDir . '/app/Http/Controllers/File/StoredFileController.php',
'App\\Http\\Controllers\\Material\\MaterialController' => $baseDir . '/app/Http/Controllers/Material/MaterialController.php',
'App\\Http\\Controllers\\Menu\\MenuController' => $baseDir . '/app/Http/Controllers/Menu/MenuController.php',
'App\\Http\\Controllers\\Menu\\MenuGroupController' => $baseDir . '/app/Http/Controllers/Menu/MenuGroupController.php',
'App\\Http\\Controllers\\Menu\\MenuListController' => $baseDir . '/app/Http/Controllers/Menu/MenuListController.php',
'App\\Http\\Controllers\\Nas\\NasResourceController' => $baseDir . '/app/Http/Controllers/Nas/NasResourceController.php',
'App\\Http\\Controllers\\Notification\\NotificationController' => $baseDir . '/app/Http/Controllers/Notification/NotificationController.php',
'App\\Http\\Controllers\\Notification\\NotificationWebhookController' => $baseDir . '/app/Http/Controllers/Notification/NotificationWebhookController.php',
'App\\Http\\Controllers\\Payment\\PaymentGatewayController' => $baseDir . '/app/Http/Controllers/Payment/PaymentGatewayController.php',
'App\\Http\\Controllers\\Profile\\ProfileController' => $baseDir . '/app/Http/Controllers/Profile/ProfileController.php',
'App\\Http\\Controllers\\Tenant\\TenantController' => $baseDir . '/app/Http/Controllers/Tenant/TenantController.php',
'App\\Http\\Controllers\\Ticket\\TicketController' => $baseDir . '/app/Http/Controllers/Ticket/TicketController.php',
'App\\Http\\Controllers\\Ticket\\TicketIncidentTypeController' => $baseDir . '/app/Http/Controllers/Ticket/TicketIncidentTypeController.php',
'App\\Http\\Controllers\\Ticket\\TicketTypeController' => $baseDir . '/app/Http/Controllers/Ticket/TicketTypeController.php',
'App\\Http\\Controllers\\Ticket\\TicketWorkflowController' => $baseDir . '/app/Http/Controllers/Ticket/TicketWorkflowController.php',
'App\\Http\\Controllers\\Topology\\TopologyController' => $baseDir . '/app/Http/Controllers/Topology/TopologyController.php',
'App\\Http\\Controllers\\User\\UserController' => $baseDir . '/app/Http/Controllers/User/UserController.php',
'App\\Http\\Controllers\\Voucher\\VoucherController' => $baseDir . '/app/Http/Controllers/Voucher/VoucherController.php',
'App\\Http\\Controllers\\Wallet\\WalletController' => $baseDir . '/app/Http/Controllers/Wallet/WalletController.php',
'App\\Http\\Controllers\\Wilayah\\WilayahController' => $baseDir . '/app/Http/Controllers/Wilayah/WilayahController.php',
'App\\Http\\Middleware\\EnsureAccessLevel' => $baseDir . '/app/Http/Middleware/EnsureAccessLevel.php',
'App\\Http\\Middleware\\EnsureMenuPermission' => $baseDir . '/app/Http/Middleware/EnsureMenuPermission.php',
'App\\Http\\Middleware\\EnsureMenuResourcePermission' => $baseDir . '/app/Http/Middleware/EnsureMenuResourcePermission.php',
'App\\Http\\Middleware\\ResolveTenantContext' => $baseDir . '/app/Http/Middleware/ResolveTenantContext.php',
'App\\Http\\Requests\\Access\\ReviewRoleApplicationRequest' => $baseDir . '/app/Http/Requests/Access/ReviewRoleApplicationRequest.php',
'App\\Http\\Requests\\Access\\StoreRoleApplicationRequest' => $baseDir . '/app/Http/Requests/Access/StoreRoleApplicationRequest.php',
'App\\Http\\Requests\\Audit\\AuditLogFilterRequest' => $baseDir . '/app/Http/Requests/Audit/AuditLogFilterRequest.php',
'App\\Http\\Requests\\Auth\\RegisterRequest' => $baseDir . '/app/Http/Requests/Auth/RegisterRequest.php',
'App\\Http\\Requests\\Auth\\ResendVerificationCodeRequest' => $baseDir . '/app/Http/Requests/Auth/ResendVerificationCodeRequest.php',
'App\\Http\\Requests\\Auth\\VerifyRegistrationRequest' => $baseDir . '/app/Http/Requests/Auth/VerifyRegistrationRequest.php',
'App\\Http\\Requests\\Billing\\IndexBillingRequest' => $baseDir . '/app/Http/Requests/Billing/IndexBillingRequest.php',
'App\\Http\\Requests\\Billing\\PayInvoiceRequest' => $baseDir . '/app/Http/Requests/Billing/PayInvoiceRequest.php',
'App\\Http\\Requests\\Billing\\StoreBillingProfileRequest' => $baseDir . '/app/Http/Requests/Billing/StoreBillingProfileRequest.php',
'App\\Http\\Requests\\Billing\\StoreInvoiceRequest' => $baseDir . '/app/Http/Requests/Billing/StoreInvoiceRequest.php',
'App\\Http\\Requests\\Billing\\UpdateBillingProfileRequest' => $baseDir . '/app/Http/Requests/Billing/UpdateBillingProfileRequest.php',
'App\\Http\\Requests\\Billing\\UpdateInvoiceRequest' => $baseDir . '/app/Http/Requests/Billing/UpdateInvoiceRequest.php',
'App\\Http\\Requests\\Customer\\ActivateCustomerRequest' => $baseDir . '/app/Http/Requests/Customer/ActivateCustomerRequest.php',
'App\\Http\\Requests\\Customer\\IndexCustomerRequest' => $baseDir . '/app/Http/Requests/Customer/IndexCustomerRequest.php',
'App\\Http\\Requests\\Customer\\StoreCustomerRequest' => $baseDir . '/app/Http/Requests/Customer/StoreCustomerRequest.php',
'App\\Http\\Requests\\Customer\\UpdateCustomerRequest' => $baseDir . '/app/Http/Requests/Customer/UpdateCustomerRequest.php',
'App\\Http\\Requests\\File\\IndexStoredFileRequest' => $baseDir . '/app/Http/Requests/File/IndexStoredFileRequest.php',
'App\\Http\\Requests\\File\\StoreFileRequest' => $baseDir . '/app/Http/Requests/File/StoreFileRequest.php',
'App\\Http\\Requests\\Material\\AssignMaterialRequest' => $baseDir . '/app/Http/Requests/Material/AssignMaterialRequest.php',
'App\\Http\\Requests\\Material\\IndexMaterialRequest' => $baseDir . '/app/Http/Requests/Material/IndexMaterialRequest.php',
'App\\Http\\Requests\\Material\\ReturnMaterialRequest' => $baseDir . '/app/Http/Requests/Material/ReturnMaterialRequest.php',
@@ -66,6 +96,19 @@ return array(
'App\\Http\\Requests\\MenuList\\IndexMenuListRequest' => $baseDir . '/app/Http/Requests/MenuList/IndexMenuListRequest.php',
'App\\Http\\Requests\\MenuList\\StoreMenuListRequest' => $baseDir . '/app/Http/Requests/MenuList/StoreMenuListRequest.php',
'App\\Http\\Requests\\MenuList\\UpdateMenuListRequest' => $baseDir . '/app/Http/Requests/MenuList/UpdateMenuListRequest.php',
'App\\Http\\Requests\\Nas\\IndexNasResourceRequest' => $baseDir . '/app/Http/Requests/Nas/IndexNasResourceRequest.php',
'App\\Http\\Requests\\Nas\\StoreNasResourceRequest' => $baseDir . '/app/Http/Requests/Nas/StoreNasResourceRequest.php',
'App\\Http\\Requests\\Nas\\UpdateNasResourceRequest' => $baseDir . '/app/Http/Requests/Nas/UpdateNasResourceRequest.php',
'App\\Http\\Requests\\Notification\\IndexNotificationRequest' => $baseDir . '/app/Http/Requests/Notification/IndexNotificationRequest.php',
'App\\Http\\Requests\\Notification\\SaveNotificationPreferencesRequest' => $baseDir . '/app/Http/Requests/Notification/SaveNotificationPreferencesRequest.php',
'App\\Http\\Requests\\Notification\\StoreCustomerContactRequest' => $baseDir . '/app/Http/Requests/Notification/StoreCustomerContactRequest.php',
'App\\Http\\Requests\\Notification\\StoreNotificationBroadcastRequest' => $baseDir . '/app/Http/Requests/Notification/StoreNotificationBroadcastRequest.php',
'App\\Http\\Requests\\Notification\\StoreNotificationChannelRequest' => $baseDir . '/app/Http/Requests/Notification/StoreNotificationChannelRequest.php',
'App\\Http\\Requests\\Notification\\StoreNotificationTemplateRequest' => $baseDir . '/app/Http/Requests/Notification/StoreNotificationTemplateRequest.php',
'App\\Http\\Requests\\Notification\\StoreSystemNotificationRequest' => $baseDir . '/app/Http/Requests/Notification/StoreSystemNotificationRequest.php',
'App\\Http\\Requests\\Payment\\IndexPaymentRequest' => $baseDir . '/app/Http/Requests/Payment/IndexPaymentRequest.php',
'App\\Http\\Requests\\Payment\\SaveTenantPaymentSettingRequest' => $baseDir . '/app/Http/Requests/Payment/SaveTenantPaymentSettingRequest.php',
'App\\Http\\Requests\\Payment\\StoreGatewayAccountRequest' => $baseDir . '/app/Http/Requests/Payment/StoreGatewayAccountRequest.php',
'App\\Http\\Requests\\Profile\\UpdateProfileRequest' => $baseDir . '/app/Http/Requests/Profile/UpdateProfileRequest.php',
'App\\Http\\Requests\\Tenant\\IndexTenantRequest' => $baseDir . '/app/Http/Requests/Tenant/IndexTenantRequest.php',
'App\\Http\\Requests\\Tenant\\StoreTenantRequest' => $baseDir . '/app/Http/Requests/Tenant/StoreTenantRequest.php',
@@ -89,26 +132,87 @@ return array(
'App\\Http\\Requests\\Ticket\\StartTicketRequest' => $baseDir . '/app/Http/Requests/Ticket/StartTicketRequest.php',
'App\\Http\\Requests\\Ticket\\StoreTicketRequest' => $baseDir . '/app/Http/Requests/Ticket/StoreTicketRequest.php',
'App\\Http\\Requests\\Ticket\\UpdateTicketRequest' => $baseDir . '/app/Http/Requests/Ticket/UpdateTicketRequest.php',
'App\\Http\\Requests\\Topology\\IndexTopologyRequest' => $baseDir . '/app/Http/Requests/Topology/IndexTopologyRequest.php',
'App\\Http\\Requests\\Topology\\StoreCustomerConnectionRequest' => $baseDir . '/app/Http/Requests/Topology/StoreCustomerConnectionRequest.php',
'App\\Http\\Requests\\Topology\\StoreTopologyRequest' => $baseDir . '/app/Http/Requests/Topology/StoreTopologyRequest.php',
'App\\Http\\Requests\\Topology\\UpdateTopologyRequest' => $baseDir . '/app/Http/Requests/Topology/UpdateTopologyRequest.php',
'App\\Http\\Requests\\User\\IndexUserRequest' => $baseDir . '/app/Http/Requests/User/IndexUserRequest.php',
'App\\Http\\Requests\\User\\StoreUserRequest' => $baseDir . '/app/Http/Requests/User/StoreUserRequest.php',
'App\\Http\\Requests\\User\\UpdateUserRequest' => $baseDir . '/app/Http/Requests/User/UpdateUserRequest.php',
'App\\Http\\Requests\\Voucher\\IndexVoucherRequest' => $baseDir . '/app/Http/Requests/Voucher/IndexVoucherRequest.php',
'App\\Http\\Requests\\Voucher\\SaveVoucherLoginPageRequest' => $baseDir . '/app/Http/Requests/Voucher/SaveVoucherLoginPageRequest.php',
'App\\Http\\Requests\\Voucher\\StoreVoucherAgentRequest' => $baseDir . '/app/Http/Requests/Voucher/StoreVoucherAgentRequest.php',
'App\\Http\\Requests\\Voucher\\StoreVoucherSaleRequest' => $baseDir . '/app/Http/Requests/Voucher/StoreVoucherSaleRequest.php',
'App\\Http\\Requests\\Wallet\\StoreWithdrawalRequest' => $baseDir . '/app/Http/Requests/Wallet/StoreWithdrawalRequest.php',
'App\\Http\\Requests\\Wallet\\TransferWalletRequest' => $baseDir . '/app/Http/Requests/Wallet/TransferWalletRequest.php',
'App\\Http\\Requests\\Wilayah\\AlamatLengkapRequest' => $baseDir . '/app/Http/Requests/Wilayah/AlamatLengkapRequest.php',
'App\\Http\\Requests\\Wilayah\\IndexWilayahRequest' => $baseDir . '/app/Http/Requests/Wilayah/IndexWilayahRequest.php',
'App\\Http\\Requests\\Wilayah\\LookupWilayahRequest' => $baseDir . '/app/Http/Requests/Wilayah/LookupWilayahRequest.php',
'App\\Http\\Requests\\Wilayah\\StoreWilayahRequest' => $baseDir . '/app/Http/Requests/Wilayah/StoreWilayahRequest.php',
'App\\Http\\Requests\\Wilayah\\UpdateWilayahRequest' => $baseDir . '/app/Http/Requests/Wilayah/UpdateWilayahRequest.php',
'App\\Http\\Resources\\Audit\\AuditLogResource' => $baseDir . '/app/Http/Resources/Audit/AuditLogResource.php',
'App\\Http\\Resources\\Billing\\BillingProfileResource' => $baseDir . '/app/Http/Resources/Billing/BillingProfileResource.php',
'App\\Http\\Resources\\Billing\\InvoiceResource' => $baseDir . '/app/Http/Resources/Billing/InvoiceResource.php',
'App\\Http\\Resources\\Customer\\CustomerResource' => $baseDir . '/app/Http/Resources/Customer/CustomerResource.php',
'App\\Http\\Resources\\File\\StoredFileResource' => $baseDir . '/app/Http/Resources/File/StoredFileResource.php',
'App\\Http\\Resources\\Material\\MaterialResource' => $baseDir . '/app/Http/Resources/Material/MaterialResource.php',
'App\\Http\\Resources\\MenuGroup\\MenuGroupResource' => $baseDir . '/app/Http/Resources/MenuGroup/MenuGroupResource.php',
'App\\Http\\Resources\\MenuList\\MenuListResource' => $baseDir . '/app/Http/Resources/MenuList/MenuListResource.php',
'App\\Http\\Resources\\Nas\\NasResource' => $baseDir . '/app/Http/Resources/Nas/NasResource.php',
'App\\Http\\Resources\\Notification\\NotificationDeliveryResource' => $baseDir . '/app/Http/Resources/Notification/NotificationDeliveryResource.php',
'App\\Http\\Resources\\Notification\\SystemNotificationCampaignResource' => $baseDir . '/app/Http/Resources/Notification/SystemNotificationCampaignResource.php',
'App\\Http\\Resources\\Payment\\PaymentTransactionResource' => $baseDir . '/app/Http/Resources/Payment/PaymentTransactionResource.php',
'App\\Http\\Resources\\Tenant\\TenantResource' => $baseDir . '/app/Http/Resources/Tenant/TenantResource.php',
'App\\Http\\Resources\\TicketIncidentType\\TicketIncidentTypeResource' => $baseDir . '/app/Http/Resources/TicketIncidentType/TicketIncidentTypeResource.php',
'App\\Http\\Resources\\TicketType\\TicketTypeResource' => $baseDir . '/app/Http/Resources/TicketType/TicketTypeResource.php',
'App\\Http\\Resources\\Ticket\\TicketResource' => $baseDir . '/app/Http/Resources/Ticket/TicketResource.php',
'App\\Http\\Resources\\Topology\\TopologyResource' => $baseDir . '/app/Http/Resources/Topology/TopologyResource.php',
'App\\Http\\Resources\\User\\UserResource' => $baseDir . '/app/Http/Resources/User/UserResource.php',
'App\\Http\\Resources\\Voucher\\VoucherAgentResource' => $baseDir . '/app/Http/Resources/Voucher/VoucherAgentResource.php',
'App\\Http\\Resources\\Voucher\\VoucherSaleResource' => $baseDir . '/app/Http/Resources/Voucher/VoucherSaleResource.php',
'App\\Http\\Resources\\Wilayah\\WilayahResource' => $baseDir . '/app/Http/Resources/Wilayah/WilayahResource.php',
'App\\Jobs\\ProcessBroadcast' => $baseDir . '/app/Jobs/ProcessBroadcast.php',
'App\\Jobs\\ProcessNotificationDelivery' => $baseDir . '/app/Jobs/ProcessNotificationDelivery.php',
'App\\Jobs\\ProcessSystemNotificationCampaign' => $baseDir . '/app/Jobs/ProcessSystemNotificationCampaign.php',
'App\\Models\\AuditLog' => $baseDir . '/app/Models/AuditLog.php',
'App\\Models\\BillingProfile' => $baseDir . '/app/Models/BillingProfile.php',
'App\\Models\\Customer' => $baseDir . '/app/Models/Customer.php',
'App\\Models\\CustomerContact' => $baseDir . '/app/Models/CustomerContact.php',
'App\\Models\\CustomerImage' => $baseDir . '/app/Models/CustomerImage.php',
'App\\Models\\CustomerNasAccount' => $baseDir . '/app/Models/CustomerNasAccount.php',
'App\\Models\\CustomerUserLink' => $baseDir . '/app/Models/CustomerUserLink.php',
'App\\Models\\HotspotVoucher' => $baseDir . '/app/Models/HotspotVoucher.php',
'App\\Models\\Invoice' => $baseDir . '/app/Models/Invoice.php',
'App\\Models\\MaterialMovement' => $baseDir . '/app/Models/MaterialMovement.php',
'App\\Models\\MenuGroup' => $baseDir . '/app/Models/MenuGroup.php',
'App\\Models\\MenuGroupMenu' => $baseDir . '/app/Models/MenuGroupMenu.php',
'App\\Models\\MenuList' => $baseDir . '/app/Models/MenuList.php',
'App\\Models\\NasMikrotik' => $baseDir . '/app/Models/NasMikrotik.php',
'App\\Models\\NasOlt' => $baseDir . '/app/Models/NasOlt.php',
'App\\Models\\NasPackageProfile' => $baseDir . '/app/Models/NasPackageProfile.php',
'App\\Models\\NasWebfigDevice' => $baseDir . '/app/Models/NasWebfigDevice.php',
'App\\Models\\NotificationBroadcast' => $baseDir . '/app/Models/NotificationBroadcast.php',
'App\\Models\\NotificationBroadcastRecipient' => $baseDir . '/app/Models/NotificationBroadcastRecipient.php',
'App\\Models\\NotificationChannel' => $baseDir . '/app/Models/NotificationChannel.php',
'App\\Models\\NotificationDelivery' => $baseDir . '/app/Models/NotificationDelivery.php',
'App\\Models\\NotificationEvent' => $baseDir . '/app/Models/NotificationEvent.php',
'App\\Models\\NotificationEventOccurrence' => $baseDir . '/app/Models/NotificationEventOccurrence.php',
'App\\Models\\NotificationMessage' => $baseDir . '/app/Models/NotificationMessage.php',
'App\\Models\\NotificationPreference' => $baseDir . '/app/Models/NotificationPreference.php',
'App\\Models\\NotificationTemplate' => $baseDir . '/app/Models/NotificationTemplate.php',
'App\\Models\\NotificationWebhookLog' => $baseDir . '/app/Models/NotificationWebhookLog.php',
'App\\Models\\PaymentAttempt' => $baseDir . '/app/Models/PaymentAttempt.php',
'App\\Models\\PaymentGatewayAccount' => $baseDir . '/app/Models/PaymentGatewayAccount.php',
'App\\Models\\PaymentProvider' => $baseDir . '/app/Models/PaymentProvider.php',
'App\\Models\\PaymentTransaction' => $baseDir . '/app/Models/PaymentTransaction.php',
'App\\Models\\PaymentWebhookLog' => $baseDir . '/app/Models/PaymentWebhookLog.php',
'App\\Models\\RoleApplication' => $baseDir . '/app/Models/RoleApplication.php',
'App\\Models\\StoredFile' => $baseDir . '/app/Models/StoredFile.php',
'App\\Models\\SystemNotificationCampaign' => $baseDir . '/app/Models/SystemNotificationCampaign.php',
'App\\Models\\TechnicianConsumable' => $baseDir . '/app/Models/TechnicianConsumable.php',
'App\\Models\\TechnicianMaterial' => $baseDir . '/app/Models/TechnicianMaterial.php',
'App\\Models\\Tenant' => $baseDir . '/app/Models/Tenant.php',
'App\\Models\\TenantPaymentSetting' => $baseDir . '/app/Models/TenantPaymentSetting.php',
'App\\Models\\Ticket' => $baseDir . '/app/Models/Ticket.php',
'App\\Models\\TicketApproval' => $baseDir . '/app/Models/TicketApproval.php',
'App\\Models\\TicketAssignment' => $baseDir . '/app/Models/TicketAssignment.php',
@@ -117,24 +221,66 @@ return array(
'App\\Models\\TicketLog' => $baseDir . '/app/Models/TicketLog.php',
'App\\Models\\TicketMaterial' => $baseDir . '/app/Models/TicketMaterial.php',
'App\\Models\\TicketType' => $baseDir . '/app/Models/TicketType.php',
'App\\Models\\TopologyConnectionRule' => $baseDir . '/app/Models/TopologyConnectionRule.php',
'App\\Models\\TopologyCustomerConnection' => $baseDir . '/app/Models/TopologyCustomerConnection.php',
'App\\Models\\TopologyDeviceType' => $baseDir . '/app/Models/TopologyDeviceType.php',
'App\\Models\\TopologyLink' => $baseDir . '/app/Models/TopologyLink.php',
'App\\Models\\TopologyNode' => $baseDir . '/app/Models/TopologyNode.php',
'App\\Models\\TopologyPort' => $baseDir . '/app/Models/TopologyPort.php',
'App\\Models\\TopologySplitter' => $baseDir . '/app/Models/TopologySplitter.php',
'App\\Models\\User' => $baseDir . '/app/Models/User.php',
'App\\Models\\UserMenuGroup' => $baseDir . '/app/Models/UserMenuGroup.php',
'App\\Models\\UserNotification' => $baseDir . '/app/Models/UserNotification.php',
'App\\Models\\UserProfile' => $baseDir . '/app/Models/UserProfile.php',
'App\\Models\\UserTenant' => $baseDir . '/app/Models/UserTenant.php',
'App\\Models\\VoucherAgent' => $baseDir . '/app/Models/VoucherAgent.php',
'App\\Models\\VoucherLoginPageSetting' => $baseDir . '/app/Models/VoucherLoginPageSetting.php',
'App\\Models\\VoucherSale' => $baseDir . '/app/Models/VoucherSale.php',
'App\\Models\\Wallet' => $baseDir . '/app/Models/Wallet.php',
'App\\Models\\WalletLedgerEntry' => $baseDir . '/app/Models/WalletLedgerEntry.php',
'App\\Models\\WalletTransaction' => $baseDir . '/app/Models/WalletTransaction.php',
'App\\Models\\WalletWithdrawal' => $baseDir . '/app/Models/WalletWithdrawal.php',
'App\\Models\\WilayahDesa' => $baseDir . '/app/Models/WilayahDesa.php',
'App\\Models\\WilayahKabupaten' => $baseDir . '/app/Models/WilayahKabupaten.php',
'App\\Models\\WilayahKecamatan' => $baseDir . '/app/Models/WilayahKecamatan.php',
'App\\Models\\WilayahProvinsi' => $baseDir . '/app/Models/WilayahProvinsi.php',
'App\\Providers\\AppServiceProvider' => $baseDir . '/app/Providers/AppServiceProvider.php',
'App\\Services\\Access\\AccessService' => $baseDir . '/app/Services/Access/AccessService.php',
'App\\Services\\Access\\RoleApplicationService' => $baseDir . '/app/Services/Access/RoleApplicationService.php',
'App\\Services\\Audit\\AuditLogService' => $baseDir . '/app/Services/Audit/AuditLogService.php',
'App\\Services\\Auth\\AuthService' => $baseDir . '/app/Services/Auth/AuthService.php',
'App\\Services\\Billing\\BillingProfileService' => $baseDir . '/app/Services/Billing/BillingProfileService.php',
'App\\Services\\Billing\\InvoiceService' => $baseDir . '/app/Services/Billing/InvoiceService.php',
'App\\Services\\Customer\\CustomerService' => $baseDir . '/app/Services/Customer/CustomerService.php',
'App\\Services\\File\\StoredFileService' => $baseDir . '/app/Services/File/StoredFileService.php',
'App\\Services\\Material\\MaterialService' => $baseDir . '/app/Services/Material/MaterialService.php',
'App\\Services\\Menu\\MenuGroupService' => $baseDir . '/app/Services/Menu/MenuGroupService.php',
'App\\Services\\Menu\\MenuListService' => $baseDir . '/app/Services/Menu/MenuListService.php',
'App\\Services\\Nas\\CustomerRouterOsProvisioningService' => $baseDir . '/app/Services/Nas/CustomerRouterOsProvisioningService.php',
'App\\Services\\Nas\\MikrotikHealthService' => $baseDir . '/app/Services/Nas/MikrotikHealthService.php',
'App\\Services\\Nas\\NasResourceService' => $baseDir . '/app/Services/Nas/NasResourceService.php',
'App\\Services\\Nas\\RouterOsApiService' => $baseDir . '/app/Services/Nas/RouterOsApiService.php',
'App\\Services\\Notification\\Adapters\\EmailAdapter' => $baseDir . '/app/Services/Notification/Adapters/EmailAdapter.php',
'App\\Services\\Notification\\Adapters\\SystemAdapter' => $baseDir . '/app/Services/Notification/Adapters/SystemAdapter.php',
'App\\Services\\Notification\\Adapters\\TelegramAdapter' => $baseDir . '/app/Services/Notification/Adapters/TelegramAdapter.php',
'App\\Services\\Notification\\Adapters\\WhatsAppOfficialAdapter' => $baseDir . '/app/Services/Notification/Adapters/WhatsAppOfficialAdapter.php',
'App\\Services\\Notification\\Adapters\\WhatsAppUnofficialAdapter' => $baseDir . '/app/Services/Notification/Adapters/WhatsAppUnofficialAdapter.php',
'App\\Services\\Notification\\Contracts\\NotificationChannelAdapter' => $baseDir . '/app/Services/Notification/Contracts/NotificationChannelAdapter.php',
'App\\Services\\Notification\\NotificationManager' => $baseDir . '/app/Services/Notification/NotificationManager.php',
'App\\Services\\Notification\\NotificationService' => $baseDir . '/app/Services/Notification/NotificationService.php',
'App\\Services\\Notification\\WhapiIdService' => $baseDir . '/app/Services/Notification/WhapiIdService.php',
'App\\Services\\Payment\\PaymentGatewayService' => $baseDir . '/app/Services/Payment/PaymentGatewayService.php',
'App\\Services\\Tenant\\TenantService' => $baseDir . '/app/Services/Tenant/TenantService.php',
'App\\Services\\Ticket\\TicketIncidentTypeService' => $baseDir . '/app/Services/Ticket/TicketIncidentTypeService.php',
'App\\Services\\Ticket\\TicketService' => $baseDir . '/app/Services/Ticket/TicketService.php',
'App\\Services\\Ticket\\TicketTypeService' => $baseDir . '/app/Services/Ticket/TicketTypeService.php',
'App\\Services\\Ticket\\TicketWorkflowService' => $baseDir . '/app/Services/Ticket/TicketWorkflowService.php',
'App\\Services\\Ticket\\TicketWorkflowValidator' => $baseDir . '/app/Services/Ticket/TicketWorkflowValidator.php',
'App\\Services\\Topology\\TopologyService' => $baseDir . '/app/Services/Topology/TopologyService.php',
'App\\Services\\User\\UserService' => $baseDir . '/app/Services/User/UserService.php',
'App\\Services\\Voucher\\VoucherService' => $baseDir . '/app/Services/Voucher/VoucherService.php',
'App\\Services\\Wallet\\WalletService' => $baseDir . '/app/Services/Wallet/WalletService.php',
'App\\Services\\Wilayah\\WilayahService' => $baseDir . '/app/Services/Wilayah/WilayahService.php',
'App\\Traits\\ApiResponse' => $baseDir . '/app/Traits/ApiResponse.php',
'App\\Traits\\Auditable' => $baseDir . '/app/Traits/Auditable.php',
'Attribute' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/Attribute.php',
@@ -1510,7 +1656,10 @@ return array(
'Database\\Factories\\UserFactory' => $baseDir . '/database/factories/UserFactory.php',
'Database\\Seeders\\DatabaseSeeder' => $baseDir . '/database/seeders/DatabaseSeeder.php',
'Database\\Seeders\\MenuListSeeder' => $baseDir . '/database/seeders/MenuListSeeder.php',
'Database\\Seeders\\NotificationSeeder' => $baseDir . '/database/seeders/NotificationSeeder.php',
'Database\\Seeders\\PaymentProviderSeeder' => $baseDir . '/database/seeders/PaymentProviderSeeder.php',
'Database\\Seeders\\TicketTypeSeeder' => $baseDir . '/database/seeders/TicketTypeSeeder.php',
'Database\\Seeders\\TopologySeeder' => $baseDir . '/database/seeders/TopologySeeder.php',
'DateError' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateError.php',
'DateException' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateException.php',
'DateInvalidOperationException' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateInvalidOperationException.php',
@@ -7103,6 +7252,32 @@ return array(
'Ramsey\\Uuid\\Validator\\ValidatorInterface' => $vendorDir . '/ramsey/uuid/src/Validator/ValidatorInterface.php',
'ReflectionConstant' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/ReflectionConstant.php',
'RoundingMode' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/RoundingMode.php',
'RouterOS\\APIConnector' => $vendorDir . '/evilfreelancer/routeros-api-php/src/APIConnector.php',
'RouterOS\\APILengthCoDec' => $vendorDir . '/evilfreelancer/routeros-api-php/src/APILengthCoDec.php',
'RouterOS\\Client' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Client.php',
'RouterOS\\Config' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Config.php',
'RouterOS\\Exceptions\\BadCredentialsException' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Exceptions/BadCredentialsException.php',
'RouterOS\\Exceptions\\ClientException' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Exceptions/ClientException.php',
'RouterOS\\Exceptions\\ConfigException' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Exceptions/ConfigException.php',
'RouterOS\\Exceptions\\ConnectException' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Exceptions/ConnectException.php',
'RouterOS\\Exceptions\\QueryException' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Exceptions/QueryException.php',
'RouterOS\\Exceptions\\StreamException' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Exceptions/StreamException.php',
'RouterOS\\Helpers\\ArrayHelper' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Helpers/ArrayHelper.php',
'RouterOS\\Helpers\\BinaryStringHelper' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Helpers/BinaryStringHelper.php',
'RouterOS\\Helpers\\TypeHelper' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Helpers/TypeHelper.php',
'RouterOS\\Interfaces\\ClientInterface' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Interfaces/ClientInterface.php',
'RouterOS\\Interfaces\\ConfigInterface' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Interfaces/ConfigInterface.php',
'RouterOS\\Interfaces\\QueryInterface' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Interfaces/QueryInterface.php',
'RouterOS\\Interfaces\\StreamInterface' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Interfaces/StreamInterface.php',
'RouterOS\\Laravel\\Facade' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Laravel/Facade.php',
'RouterOS\\Laravel\\ServiceProvider' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Laravel/ServiceProvider.php',
'RouterOS\\Laravel\\Wrapper' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Laravel/Wrapper.php',
'RouterOS\\Query' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Query.php',
'RouterOS\\ResponseIterator' => $vendorDir . '/evilfreelancer/routeros-api-php/src/ResponseIterator.php',
'RouterOS\\ShortsTrait' => $vendorDir . '/evilfreelancer/routeros-api-php/src/ShortsTrait.php',
'RouterOS\\SocketTrait' => $vendorDir . '/evilfreelancer/routeros-api-php/src/SocketTrait.php',
'RouterOS\\Streams\\ResourceStream' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Streams/ResourceStream.php',
'RouterOS\\Streams\\StringStream' => $vendorDir . '/evilfreelancer/routeros-api-php/src/Streams/StringStream.php',
'SQLite3Exception' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/SQLite3Exception.php',
'SebastianBergmann\\CliParser\\AmbiguousOptionException' => $vendorDir . '/sebastian/cli-parser/src/exceptions/AmbiguousOptionException.php',
'SebastianBergmann\\CliParser\\Exception' => $vendorDir . '/sebastian/cli-parser/src/exceptions/Exception.php',
@@ -7344,6 +7519,7 @@ return array(
'Spatie\\LaravelPackageTools\\Exceptions\\InvalidPackage' => $vendorDir . '/spatie/laravel-package-tools/src/Exceptions/InvalidPackage.php',
'Spatie\\LaravelPackageTools\\Package' => $vendorDir . '/spatie/laravel-package-tools/src/Package.php',
'Spatie\\LaravelPackageTools\\PackageServiceProvider' => $vendorDir . '/spatie/laravel-package-tools/src/PackageServiceProvider.php',
'Spatie\\Ssh\\Ssh' => $vendorDir . '/spatie/ssh/src/Ssh.php',
'Stringable' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/Stringable.php',
'Symfony\\Component\\Clock\\Clock' => $vendorDir . '/symfony/clock/Clock.php',
'Symfony\\Component\\Clock\\ClockAwareTrait' => $vendorDir . '/symfony/clock/ClockAwareTrait.php',
@@ -8406,8 +8582,13 @@ return array(
'Termwind\\ValueObjects\\Node' => $vendorDir . '/nunomaduro/termwind/src/ValueObjects/Node.php',
'Termwind\\ValueObjects\\Style' => $vendorDir . '/nunomaduro/termwind/src/ValueObjects/Style.php',
'Termwind\\ValueObjects\\Styles' => $vendorDir . '/nunomaduro/termwind/src/ValueObjects/Styles.php',
'Tests\\Feature\\BillingWorkflowTest' => $baseDir . '/tests/Feature/BillingWorkflowTest.php',
'Tests\\Feature\\CustomerWorkflowTest' => $baseDir . '/tests/Feature/CustomerWorkflowTest.php',
'Tests\\Feature\\ExampleTest' => $baseDir . '/tests/Feature/ExampleTest.php',
'Tests\\Feature\\MenuAccessTest' => $baseDir . '/tests/Feature/MenuAccessTest.php',
'Tests\\Feature\\NasResourceServiceTest' => $baseDir . '/tests/Feature/NasResourceServiceTest.php',
'Tests\\Feature\\StoredFileApiTest' => $baseDir . '/tests/Feature/StoredFileApiTest.php',
'Tests\\Feature\\WilayahApiTest' => $baseDir . '/tests/Feature/WilayahApiTest.php',
'Tests\\TestCase' => $baseDir . '/tests/TestCase.php',
'Tests\\Unit\\ExampleTest' => $baseDir . '/tests/Unit/ExampleTest.php',
'TheSeer\\Tokenizer\\Exception' => $vendorDir . '/theseer/tokenizer/src/Exception.php',
+2
View File
@@ -42,7 +42,9 @@ return array(
'Symfony\\Component\\CssSelector\\' => array($vendorDir . '/symfony/css-selector'),
'Symfony\\Component\\Console\\' => array($vendorDir . '/symfony/console'),
'Symfony\\Component\\Clock\\' => array($vendorDir . '/symfony/clock'),
'Spatie\\Ssh\\' => array($vendorDir . '/spatie/ssh/src'),
'Spatie\\LaravelPackageTools\\' => array($vendorDir . '/spatie/laravel-package-tools/src'),
'RouterOS\\' => array($vendorDir . '/evilfreelancer/routeros-api-php/src'),
'Ramsey\\Uuid\\' => array($vendorDir . '/ramsey/uuid/src'),
'Ramsey\\Collection\\' => array($vendorDir . '/ramsey/collection/src'),
'Psy\\' => array($vendorDir . '/psy/psysh/src'),
+191
View File
@@ -94,10 +94,12 @@ class ComposerStaticInitd8f67b824a1bf31c8999ead558d9b485
'Symfony\\Component\\CssSelector\\' => 30,
'Symfony\\Component\\Console\\' => 26,
'Symfony\\Component\\Clock\\' => 24,
'Spatie\\Ssh\\' => 11,
'Spatie\\LaravelPackageTools\\' => 27,
),
'R' =>
array (
'RouterOS\\' => 9,
'Ramsey\\Uuid\\' => 12,
'Ramsey\\Collection\\' => 18,
),
@@ -341,10 +343,18 @@ class ComposerStaticInitd8f67b824a1bf31c8999ead558d9b485
array (
0 => __DIR__ . '/..' . '/symfony/clock',
),
'Spatie\\Ssh\\' =>
array (
0 => __DIR__ . '/..' . '/spatie/ssh/src',
),
'Spatie\\LaravelPackageTools\\' =>
array (
0 => __DIR__ . '/..' . '/spatie/laravel-package-tools/src',
),
'RouterOS\\' =>
array (
0 => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src',
),
'Ramsey\\Uuid\\' =>
array (
0 => __DIR__ . '/..' . '/ramsey/uuid/src',
@@ -604,31 +614,61 @@ class ComposerStaticInitd8f67b824a1bf31c8999ead558d9b485
'AWS\\CRT\\NativeResource' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/NativeResource.php',
'AWS\\CRT\\OptionValue' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Options.php',
'AWS\\CRT\\Options' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Options.php',
'App\\Console\\Commands\\CheckMikrotikConnections' => __DIR__ . '/../..' . '/app/Console/Commands/CheckMikrotikConnections.php',
'App\\Console\\Commands\\ProcessBillingSchedules' => __DIR__ . '/../..' . '/app/Console/Commands/ProcessBillingSchedules.php',
'App\\Console\\Commands\\PruneNotifications' => __DIR__ . '/../..' . '/app/Console/Commands/PruneNotifications.php',
'App\\Enums\\TicketStatus' => __DIR__ . '/../..' . '/app/Enums/TicketStatus.php',
'App\\Enums\\UserAccessLevel' => __DIR__ . '/../..' . '/app/Enums/UserAccessLevel.php',
'App\\Http\\Controllers\\Access\\RoleApplicationController' => __DIR__ . '/../..' . '/app/Http/Controllers/Access/RoleApplicationController.php',
'App\\Http\\Controllers\\ApiController' => __DIR__ . '/../..' . '/app/Http/Controllers/ApiController.php',
'App\\Http\\Controllers\\Audit\\AuditLogController' => __DIR__ . '/../..' . '/app/Http/Controllers/Audit/AuditLogController.php',
'App\\Http\\Controllers\\AuthController' => __DIR__ . '/../..' . '/app/Http/Controllers/AuthController.php',
'App\\Http\\Controllers\\Billing\\BillingProfileController' => __DIR__ . '/../..' . '/app/Http/Controllers/Billing/BillingProfileController.php',
'App\\Http\\Controllers\\Billing\\InvoiceController' => __DIR__ . '/../..' . '/app/Http/Controllers/Billing/InvoiceController.php',
'App\\Http\\Controllers\\Controller' => __DIR__ . '/../..' . '/app/Http/Controllers/Controller.php',
'App\\Http\\Controllers\\Customer\\CustomerController' => __DIR__ . '/../..' . '/app/Http/Controllers/Customer/CustomerController.php',
'App\\Http\\Controllers\\File\\StoredFileController' => __DIR__ . '/../..' . '/app/Http/Controllers/File/StoredFileController.php',
'App\\Http\\Controllers\\Material\\MaterialController' => __DIR__ . '/../..' . '/app/Http/Controllers/Material/MaterialController.php',
'App\\Http\\Controllers\\Menu\\MenuController' => __DIR__ . '/../..' . '/app/Http/Controllers/Menu/MenuController.php',
'App\\Http\\Controllers\\Menu\\MenuGroupController' => __DIR__ . '/../..' . '/app/Http/Controllers/Menu/MenuGroupController.php',
'App\\Http\\Controllers\\Menu\\MenuListController' => __DIR__ . '/../..' . '/app/Http/Controllers/Menu/MenuListController.php',
'App\\Http\\Controllers\\Nas\\NasResourceController' => __DIR__ . '/../..' . '/app/Http/Controllers/Nas/NasResourceController.php',
'App\\Http\\Controllers\\Notification\\NotificationController' => __DIR__ . '/../..' . '/app/Http/Controllers/Notification/NotificationController.php',
'App\\Http\\Controllers\\Notification\\NotificationWebhookController' => __DIR__ . '/../..' . '/app/Http/Controllers/Notification/NotificationWebhookController.php',
'App\\Http\\Controllers\\Payment\\PaymentGatewayController' => __DIR__ . '/../..' . '/app/Http/Controllers/Payment/PaymentGatewayController.php',
'App\\Http\\Controllers\\Profile\\ProfileController' => __DIR__ . '/../..' . '/app/Http/Controllers/Profile/ProfileController.php',
'App\\Http\\Controllers\\Tenant\\TenantController' => __DIR__ . '/../..' . '/app/Http/Controllers/Tenant/TenantController.php',
'App\\Http\\Controllers\\Ticket\\TicketController' => __DIR__ . '/../..' . '/app/Http/Controllers/Ticket/TicketController.php',
'App\\Http\\Controllers\\Ticket\\TicketIncidentTypeController' => __DIR__ . '/../..' . '/app/Http/Controllers/Ticket/TicketIncidentTypeController.php',
'App\\Http\\Controllers\\Ticket\\TicketTypeController' => __DIR__ . '/../..' . '/app/Http/Controllers/Ticket/TicketTypeController.php',
'App\\Http\\Controllers\\Ticket\\TicketWorkflowController' => __DIR__ . '/../..' . '/app/Http/Controllers/Ticket/TicketWorkflowController.php',
'App\\Http\\Controllers\\Topology\\TopologyController' => __DIR__ . '/../..' . '/app/Http/Controllers/Topology/TopologyController.php',
'App\\Http\\Controllers\\User\\UserController' => __DIR__ . '/../..' . '/app/Http/Controllers/User/UserController.php',
'App\\Http\\Controllers\\Voucher\\VoucherController' => __DIR__ . '/../..' . '/app/Http/Controllers/Voucher/VoucherController.php',
'App\\Http\\Controllers\\Wallet\\WalletController' => __DIR__ . '/../..' . '/app/Http/Controllers/Wallet/WalletController.php',
'App\\Http\\Controllers\\Wilayah\\WilayahController' => __DIR__ . '/../..' . '/app/Http/Controllers/Wilayah/WilayahController.php',
'App\\Http\\Middleware\\EnsureAccessLevel' => __DIR__ . '/../..' . '/app/Http/Middleware/EnsureAccessLevel.php',
'App\\Http\\Middleware\\EnsureMenuPermission' => __DIR__ . '/../..' . '/app/Http/Middleware/EnsureMenuPermission.php',
'App\\Http\\Middleware\\EnsureMenuResourcePermission' => __DIR__ . '/../..' . '/app/Http/Middleware/EnsureMenuResourcePermission.php',
'App\\Http\\Middleware\\ResolveTenantContext' => __DIR__ . '/../..' . '/app/Http/Middleware/ResolveTenantContext.php',
'App\\Http\\Requests\\Access\\ReviewRoleApplicationRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Access/ReviewRoleApplicationRequest.php',
'App\\Http\\Requests\\Access\\StoreRoleApplicationRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Access/StoreRoleApplicationRequest.php',
'App\\Http\\Requests\\Audit\\AuditLogFilterRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Audit/AuditLogFilterRequest.php',
'App\\Http\\Requests\\Auth\\RegisterRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Auth/RegisterRequest.php',
'App\\Http\\Requests\\Auth\\ResendVerificationCodeRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Auth/ResendVerificationCodeRequest.php',
'App\\Http\\Requests\\Auth\\VerifyRegistrationRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Auth/VerifyRegistrationRequest.php',
'App\\Http\\Requests\\Billing\\IndexBillingRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Billing/IndexBillingRequest.php',
'App\\Http\\Requests\\Billing\\PayInvoiceRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Billing/PayInvoiceRequest.php',
'App\\Http\\Requests\\Billing\\StoreBillingProfileRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Billing/StoreBillingProfileRequest.php',
'App\\Http\\Requests\\Billing\\StoreInvoiceRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Billing/StoreInvoiceRequest.php',
'App\\Http\\Requests\\Billing\\UpdateBillingProfileRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Billing/UpdateBillingProfileRequest.php',
'App\\Http\\Requests\\Billing\\UpdateInvoiceRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Billing/UpdateInvoiceRequest.php',
'App\\Http\\Requests\\Customer\\ActivateCustomerRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Customer/ActivateCustomerRequest.php',
'App\\Http\\Requests\\Customer\\IndexCustomerRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Customer/IndexCustomerRequest.php',
'App\\Http\\Requests\\Customer\\StoreCustomerRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Customer/StoreCustomerRequest.php',
'App\\Http\\Requests\\Customer\\UpdateCustomerRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Customer/UpdateCustomerRequest.php',
'App\\Http\\Requests\\File\\IndexStoredFileRequest' => __DIR__ . '/../..' . '/app/Http/Requests/File/IndexStoredFileRequest.php',
'App\\Http\\Requests\\File\\StoreFileRequest' => __DIR__ . '/../..' . '/app/Http/Requests/File/StoreFileRequest.php',
'App\\Http\\Requests\\Material\\AssignMaterialRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Material/AssignMaterialRequest.php',
'App\\Http\\Requests\\Material\\IndexMaterialRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Material/IndexMaterialRequest.php',
'App\\Http\\Requests\\Material\\ReturnMaterialRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Material/ReturnMaterialRequest.php',
@@ -641,6 +681,19 @@ class ComposerStaticInitd8f67b824a1bf31c8999ead558d9b485
'App\\Http\\Requests\\MenuList\\IndexMenuListRequest' => __DIR__ . '/../..' . '/app/Http/Requests/MenuList/IndexMenuListRequest.php',
'App\\Http\\Requests\\MenuList\\StoreMenuListRequest' => __DIR__ . '/../..' . '/app/Http/Requests/MenuList/StoreMenuListRequest.php',
'App\\Http\\Requests\\MenuList\\UpdateMenuListRequest' => __DIR__ . '/../..' . '/app/Http/Requests/MenuList/UpdateMenuListRequest.php',
'App\\Http\\Requests\\Nas\\IndexNasResourceRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Nas/IndexNasResourceRequest.php',
'App\\Http\\Requests\\Nas\\StoreNasResourceRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Nas/StoreNasResourceRequest.php',
'App\\Http\\Requests\\Nas\\UpdateNasResourceRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Nas/UpdateNasResourceRequest.php',
'App\\Http\\Requests\\Notification\\IndexNotificationRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Notification/IndexNotificationRequest.php',
'App\\Http\\Requests\\Notification\\SaveNotificationPreferencesRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Notification/SaveNotificationPreferencesRequest.php',
'App\\Http\\Requests\\Notification\\StoreCustomerContactRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Notification/StoreCustomerContactRequest.php',
'App\\Http\\Requests\\Notification\\StoreNotificationBroadcastRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Notification/StoreNotificationBroadcastRequest.php',
'App\\Http\\Requests\\Notification\\StoreNotificationChannelRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Notification/StoreNotificationChannelRequest.php',
'App\\Http\\Requests\\Notification\\StoreNotificationTemplateRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Notification/StoreNotificationTemplateRequest.php',
'App\\Http\\Requests\\Notification\\StoreSystemNotificationRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Notification/StoreSystemNotificationRequest.php',
'App\\Http\\Requests\\Payment\\IndexPaymentRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Payment/IndexPaymentRequest.php',
'App\\Http\\Requests\\Payment\\SaveTenantPaymentSettingRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Payment/SaveTenantPaymentSettingRequest.php',
'App\\Http\\Requests\\Payment\\StoreGatewayAccountRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Payment/StoreGatewayAccountRequest.php',
'App\\Http\\Requests\\Profile\\UpdateProfileRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Profile/UpdateProfileRequest.php',
'App\\Http\\Requests\\Tenant\\IndexTenantRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Tenant/IndexTenantRequest.php',
'App\\Http\\Requests\\Tenant\\StoreTenantRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Tenant/StoreTenantRequest.php',
@@ -664,26 +717,87 @@ class ComposerStaticInitd8f67b824a1bf31c8999ead558d9b485
'App\\Http\\Requests\\Ticket\\StartTicketRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Ticket/StartTicketRequest.php',
'App\\Http\\Requests\\Ticket\\StoreTicketRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Ticket/StoreTicketRequest.php',
'App\\Http\\Requests\\Ticket\\UpdateTicketRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Ticket/UpdateTicketRequest.php',
'App\\Http\\Requests\\Topology\\IndexTopologyRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Topology/IndexTopologyRequest.php',
'App\\Http\\Requests\\Topology\\StoreCustomerConnectionRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Topology/StoreCustomerConnectionRequest.php',
'App\\Http\\Requests\\Topology\\StoreTopologyRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Topology/StoreTopologyRequest.php',
'App\\Http\\Requests\\Topology\\UpdateTopologyRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Topology/UpdateTopologyRequest.php',
'App\\Http\\Requests\\User\\IndexUserRequest' => __DIR__ . '/../..' . '/app/Http/Requests/User/IndexUserRequest.php',
'App\\Http\\Requests\\User\\StoreUserRequest' => __DIR__ . '/../..' . '/app/Http/Requests/User/StoreUserRequest.php',
'App\\Http\\Requests\\User\\UpdateUserRequest' => __DIR__ . '/../..' . '/app/Http/Requests/User/UpdateUserRequest.php',
'App\\Http\\Requests\\Voucher\\IndexVoucherRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Voucher/IndexVoucherRequest.php',
'App\\Http\\Requests\\Voucher\\SaveVoucherLoginPageRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Voucher/SaveVoucherLoginPageRequest.php',
'App\\Http\\Requests\\Voucher\\StoreVoucherAgentRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Voucher/StoreVoucherAgentRequest.php',
'App\\Http\\Requests\\Voucher\\StoreVoucherSaleRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Voucher/StoreVoucherSaleRequest.php',
'App\\Http\\Requests\\Wallet\\StoreWithdrawalRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Wallet/StoreWithdrawalRequest.php',
'App\\Http\\Requests\\Wallet\\TransferWalletRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Wallet/TransferWalletRequest.php',
'App\\Http\\Requests\\Wilayah\\AlamatLengkapRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Wilayah/AlamatLengkapRequest.php',
'App\\Http\\Requests\\Wilayah\\IndexWilayahRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Wilayah/IndexWilayahRequest.php',
'App\\Http\\Requests\\Wilayah\\LookupWilayahRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Wilayah/LookupWilayahRequest.php',
'App\\Http\\Requests\\Wilayah\\StoreWilayahRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Wilayah/StoreWilayahRequest.php',
'App\\Http\\Requests\\Wilayah\\UpdateWilayahRequest' => __DIR__ . '/../..' . '/app/Http/Requests/Wilayah/UpdateWilayahRequest.php',
'App\\Http\\Resources\\Audit\\AuditLogResource' => __DIR__ . '/../..' . '/app/Http/Resources/Audit/AuditLogResource.php',
'App\\Http\\Resources\\Billing\\BillingProfileResource' => __DIR__ . '/../..' . '/app/Http/Resources/Billing/BillingProfileResource.php',
'App\\Http\\Resources\\Billing\\InvoiceResource' => __DIR__ . '/../..' . '/app/Http/Resources/Billing/InvoiceResource.php',
'App\\Http\\Resources\\Customer\\CustomerResource' => __DIR__ . '/../..' . '/app/Http/Resources/Customer/CustomerResource.php',
'App\\Http\\Resources\\File\\StoredFileResource' => __DIR__ . '/../..' . '/app/Http/Resources/File/StoredFileResource.php',
'App\\Http\\Resources\\Material\\MaterialResource' => __DIR__ . '/../..' . '/app/Http/Resources/Material/MaterialResource.php',
'App\\Http\\Resources\\MenuGroup\\MenuGroupResource' => __DIR__ . '/../..' . '/app/Http/Resources/MenuGroup/MenuGroupResource.php',
'App\\Http\\Resources\\MenuList\\MenuListResource' => __DIR__ . '/../..' . '/app/Http/Resources/MenuList/MenuListResource.php',
'App\\Http\\Resources\\Nas\\NasResource' => __DIR__ . '/../..' . '/app/Http/Resources/Nas/NasResource.php',
'App\\Http\\Resources\\Notification\\NotificationDeliveryResource' => __DIR__ . '/../..' . '/app/Http/Resources/Notification/NotificationDeliveryResource.php',
'App\\Http\\Resources\\Notification\\SystemNotificationCampaignResource' => __DIR__ . '/../..' . '/app/Http/Resources/Notification/SystemNotificationCampaignResource.php',
'App\\Http\\Resources\\Payment\\PaymentTransactionResource' => __DIR__ . '/../..' . '/app/Http/Resources/Payment/PaymentTransactionResource.php',
'App\\Http\\Resources\\Tenant\\TenantResource' => __DIR__ . '/../..' . '/app/Http/Resources/Tenant/TenantResource.php',
'App\\Http\\Resources\\TicketIncidentType\\TicketIncidentTypeResource' => __DIR__ . '/../..' . '/app/Http/Resources/TicketIncidentType/TicketIncidentTypeResource.php',
'App\\Http\\Resources\\TicketType\\TicketTypeResource' => __DIR__ . '/../..' . '/app/Http/Resources/TicketType/TicketTypeResource.php',
'App\\Http\\Resources\\Ticket\\TicketResource' => __DIR__ . '/../..' . '/app/Http/Resources/Ticket/TicketResource.php',
'App\\Http\\Resources\\Topology\\TopologyResource' => __DIR__ . '/../..' . '/app/Http/Resources/Topology/TopologyResource.php',
'App\\Http\\Resources\\User\\UserResource' => __DIR__ . '/../..' . '/app/Http/Resources/User/UserResource.php',
'App\\Http\\Resources\\Voucher\\VoucherAgentResource' => __DIR__ . '/../..' . '/app/Http/Resources/Voucher/VoucherAgentResource.php',
'App\\Http\\Resources\\Voucher\\VoucherSaleResource' => __DIR__ . '/../..' . '/app/Http/Resources/Voucher/VoucherSaleResource.php',
'App\\Http\\Resources\\Wilayah\\WilayahResource' => __DIR__ . '/../..' . '/app/Http/Resources/Wilayah/WilayahResource.php',
'App\\Jobs\\ProcessBroadcast' => __DIR__ . '/../..' . '/app/Jobs/ProcessBroadcast.php',
'App\\Jobs\\ProcessNotificationDelivery' => __DIR__ . '/../..' . '/app/Jobs/ProcessNotificationDelivery.php',
'App\\Jobs\\ProcessSystemNotificationCampaign' => __DIR__ . '/../..' . '/app/Jobs/ProcessSystemNotificationCampaign.php',
'App\\Models\\AuditLog' => __DIR__ . '/../..' . '/app/Models/AuditLog.php',
'App\\Models\\BillingProfile' => __DIR__ . '/../..' . '/app/Models/BillingProfile.php',
'App\\Models\\Customer' => __DIR__ . '/../..' . '/app/Models/Customer.php',
'App\\Models\\CustomerContact' => __DIR__ . '/../..' . '/app/Models/CustomerContact.php',
'App\\Models\\CustomerImage' => __DIR__ . '/../..' . '/app/Models/CustomerImage.php',
'App\\Models\\CustomerNasAccount' => __DIR__ . '/../..' . '/app/Models/CustomerNasAccount.php',
'App\\Models\\CustomerUserLink' => __DIR__ . '/../..' . '/app/Models/CustomerUserLink.php',
'App\\Models\\HotspotVoucher' => __DIR__ . '/../..' . '/app/Models/HotspotVoucher.php',
'App\\Models\\Invoice' => __DIR__ . '/../..' . '/app/Models/Invoice.php',
'App\\Models\\MaterialMovement' => __DIR__ . '/../..' . '/app/Models/MaterialMovement.php',
'App\\Models\\MenuGroup' => __DIR__ . '/../..' . '/app/Models/MenuGroup.php',
'App\\Models\\MenuGroupMenu' => __DIR__ . '/../..' . '/app/Models/MenuGroupMenu.php',
'App\\Models\\MenuList' => __DIR__ . '/../..' . '/app/Models/MenuList.php',
'App\\Models\\NasMikrotik' => __DIR__ . '/../..' . '/app/Models/NasMikrotik.php',
'App\\Models\\NasOlt' => __DIR__ . '/../..' . '/app/Models/NasOlt.php',
'App\\Models\\NasPackageProfile' => __DIR__ . '/../..' . '/app/Models/NasPackageProfile.php',
'App\\Models\\NasWebfigDevice' => __DIR__ . '/../..' . '/app/Models/NasWebfigDevice.php',
'App\\Models\\NotificationBroadcast' => __DIR__ . '/../..' . '/app/Models/NotificationBroadcast.php',
'App\\Models\\NotificationBroadcastRecipient' => __DIR__ . '/../..' . '/app/Models/NotificationBroadcastRecipient.php',
'App\\Models\\NotificationChannel' => __DIR__ . '/../..' . '/app/Models/NotificationChannel.php',
'App\\Models\\NotificationDelivery' => __DIR__ . '/../..' . '/app/Models/NotificationDelivery.php',
'App\\Models\\NotificationEvent' => __DIR__ . '/../..' . '/app/Models/NotificationEvent.php',
'App\\Models\\NotificationEventOccurrence' => __DIR__ . '/../..' . '/app/Models/NotificationEventOccurrence.php',
'App\\Models\\NotificationMessage' => __DIR__ . '/../..' . '/app/Models/NotificationMessage.php',
'App\\Models\\NotificationPreference' => __DIR__ . '/../..' . '/app/Models/NotificationPreference.php',
'App\\Models\\NotificationTemplate' => __DIR__ . '/../..' . '/app/Models/NotificationTemplate.php',
'App\\Models\\NotificationWebhookLog' => __DIR__ . '/../..' . '/app/Models/NotificationWebhookLog.php',
'App\\Models\\PaymentAttempt' => __DIR__ . '/../..' . '/app/Models/PaymentAttempt.php',
'App\\Models\\PaymentGatewayAccount' => __DIR__ . '/../..' . '/app/Models/PaymentGatewayAccount.php',
'App\\Models\\PaymentProvider' => __DIR__ . '/../..' . '/app/Models/PaymentProvider.php',
'App\\Models\\PaymentTransaction' => __DIR__ . '/../..' . '/app/Models/PaymentTransaction.php',
'App\\Models\\PaymentWebhookLog' => __DIR__ . '/../..' . '/app/Models/PaymentWebhookLog.php',
'App\\Models\\RoleApplication' => __DIR__ . '/../..' . '/app/Models/RoleApplication.php',
'App\\Models\\StoredFile' => __DIR__ . '/../..' . '/app/Models/StoredFile.php',
'App\\Models\\SystemNotificationCampaign' => __DIR__ . '/../..' . '/app/Models/SystemNotificationCampaign.php',
'App\\Models\\TechnicianConsumable' => __DIR__ . '/../..' . '/app/Models/TechnicianConsumable.php',
'App\\Models\\TechnicianMaterial' => __DIR__ . '/../..' . '/app/Models/TechnicianMaterial.php',
'App\\Models\\Tenant' => __DIR__ . '/../..' . '/app/Models/Tenant.php',
'App\\Models\\TenantPaymentSetting' => __DIR__ . '/../..' . '/app/Models/TenantPaymentSetting.php',
'App\\Models\\Ticket' => __DIR__ . '/../..' . '/app/Models/Ticket.php',
'App\\Models\\TicketApproval' => __DIR__ . '/../..' . '/app/Models/TicketApproval.php',
'App\\Models\\TicketAssignment' => __DIR__ . '/../..' . '/app/Models/TicketAssignment.php',
@@ -692,24 +806,66 @@ class ComposerStaticInitd8f67b824a1bf31c8999ead558d9b485
'App\\Models\\TicketLog' => __DIR__ . '/../..' . '/app/Models/TicketLog.php',
'App\\Models\\TicketMaterial' => __DIR__ . '/../..' . '/app/Models/TicketMaterial.php',
'App\\Models\\TicketType' => __DIR__ . '/../..' . '/app/Models/TicketType.php',
'App\\Models\\TopologyConnectionRule' => __DIR__ . '/../..' . '/app/Models/TopologyConnectionRule.php',
'App\\Models\\TopologyCustomerConnection' => __DIR__ . '/../..' . '/app/Models/TopologyCustomerConnection.php',
'App\\Models\\TopologyDeviceType' => __DIR__ . '/../..' . '/app/Models/TopologyDeviceType.php',
'App\\Models\\TopologyLink' => __DIR__ . '/../..' . '/app/Models/TopologyLink.php',
'App\\Models\\TopologyNode' => __DIR__ . '/../..' . '/app/Models/TopologyNode.php',
'App\\Models\\TopologyPort' => __DIR__ . '/../..' . '/app/Models/TopologyPort.php',
'App\\Models\\TopologySplitter' => __DIR__ . '/../..' . '/app/Models/TopologySplitter.php',
'App\\Models\\User' => __DIR__ . '/../..' . '/app/Models/User.php',
'App\\Models\\UserMenuGroup' => __DIR__ . '/../..' . '/app/Models/UserMenuGroup.php',
'App\\Models\\UserNotification' => __DIR__ . '/../..' . '/app/Models/UserNotification.php',
'App\\Models\\UserProfile' => __DIR__ . '/../..' . '/app/Models/UserProfile.php',
'App\\Models\\UserTenant' => __DIR__ . '/../..' . '/app/Models/UserTenant.php',
'App\\Models\\VoucherAgent' => __DIR__ . '/../..' . '/app/Models/VoucherAgent.php',
'App\\Models\\VoucherLoginPageSetting' => __DIR__ . '/../..' . '/app/Models/VoucherLoginPageSetting.php',
'App\\Models\\VoucherSale' => __DIR__ . '/../..' . '/app/Models/VoucherSale.php',
'App\\Models\\Wallet' => __DIR__ . '/../..' . '/app/Models/Wallet.php',
'App\\Models\\WalletLedgerEntry' => __DIR__ . '/../..' . '/app/Models/WalletLedgerEntry.php',
'App\\Models\\WalletTransaction' => __DIR__ . '/../..' . '/app/Models/WalletTransaction.php',
'App\\Models\\WalletWithdrawal' => __DIR__ . '/../..' . '/app/Models/WalletWithdrawal.php',
'App\\Models\\WilayahDesa' => __DIR__ . '/../..' . '/app/Models/WilayahDesa.php',
'App\\Models\\WilayahKabupaten' => __DIR__ . '/../..' . '/app/Models/WilayahKabupaten.php',
'App\\Models\\WilayahKecamatan' => __DIR__ . '/../..' . '/app/Models/WilayahKecamatan.php',
'App\\Models\\WilayahProvinsi' => __DIR__ . '/../..' . '/app/Models/WilayahProvinsi.php',
'App\\Providers\\AppServiceProvider' => __DIR__ . '/../..' . '/app/Providers/AppServiceProvider.php',
'App\\Services\\Access\\AccessService' => __DIR__ . '/../..' . '/app/Services/Access/AccessService.php',
'App\\Services\\Access\\RoleApplicationService' => __DIR__ . '/../..' . '/app/Services/Access/RoleApplicationService.php',
'App\\Services\\Audit\\AuditLogService' => __DIR__ . '/../..' . '/app/Services/Audit/AuditLogService.php',
'App\\Services\\Auth\\AuthService' => __DIR__ . '/../..' . '/app/Services/Auth/AuthService.php',
'App\\Services\\Billing\\BillingProfileService' => __DIR__ . '/../..' . '/app/Services/Billing/BillingProfileService.php',
'App\\Services\\Billing\\InvoiceService' => __DIR__ . '/../..' . '/app/Services/Billing/InvoiceService.php',
'App\\Services\\Customer\\CustomerService' => __DIR__ . '/../..' . '/app/Services/Customer/CustomerService.php',
'App\\Services\\File\\StoredFileService' => __DIR__ . '/../..' . '/app/Services/File/StoredFileService.php',
'App\\Services\\Material\\MaterialService' => __DIR__ . '/../..' . '/app/Services/Material/MaterialService.php',
'App\\Services\\Menu\\MenuGroupService' => __DIR__ . '/../..' . '/app/Services/Menu/MenuGroupService.php',
'App\\Services\\Menu\\MenuListService' => __DIR__ . '/../..' . '/app/Services/Menu/MenuListService.php',
'App\\Services\\Nas\\CustomerRouterOsProvisioningService' => __DIR__ . '/../..' . '/app/Services/Nas/CustomerRouterOsProvisioningService.php',
'App\\Services\\Nas\\MikrotikHealthService' => __DIR__ . '/../..' . '/app/Services/Nas/MikrotikHealthService.php',
'App\\Services\\Nas\\NasResourceService' => __DIR__ . '/../..' . '/app/Services/Nas/NasResourceService.php',
'App\\Services\\Nas\\RouterOsApiService' => __DIR__ . '/../..' . '/app/Services/Nas/RouterOsApiService.php',
'App\\Services\\Notification\\Adapters\\EmailAdapter' => __DIR__ . '/../..' . '/app/Services/Notification/Adapters/EmailAdapter.php',
'App\\Services\\Notification\\Adapters\\SystemAdapter' => __DIR__ . '/../..' . '/app/Services/Notification/Adapters/SystemAdapter.php',
'App\\Services\\Notification\\Adapters\\TelegramAdapter' => __DIR__ . '/../..' . '/app/Services/Notification/Adapters/TelegramAdapter.php',
'App\\Services\\Notification\\Adapters\\WhatsAppOfficialAdapter' => __DIR__ . '/../..' . '/app/Services/Notification/Adapters/WhatsAppOfficialAdapter.php',
'App\\Services\\Notification\\Adapters\\WhatsAppUnofficialAdapter' => __DIR__ . '/../..' . '/app/Services/Notification/Adapters/WhatsAppUnofficialAdapter.php',
'App\\Services\\Notification\\Contracts\\NotificationChannelAdapter' => __DIR__ . '/../..' . '/app/Services/Notification/Contracts/NotificationChannelAdapter.php',
'App\\Services\\Notification\\NotificationManager' => __DIR__ . '/../..' . '/app/Services/Notification/NotificationManager.php',
'App\\Services\\Notification\\NotificationService' => __DIR__ . '/../..' . '/app/Services/Notification/NotificationService.php',
'App\\Services\\Notification\\WhapiIdService' => __DIR__ . '/../..' . '/app/Services/Notification/WhapiIdService.php',
'App\\Services\\Payment\\PaymentGatewayService' => __DIR__ . '/../..' . '/app/Services/Payment/PaymentGatewayService.php',
'App\\Services\\Tenant\\TenantService' => __DIR__ . '/../..' . '/app/Services/Tenant/TenantService.php',
'App\\Services\\Ticket\\TicketIncidentTypeService' => __DIR__ . '/../..' . '/app/Services/Ticket/TicketIncidentTypeService.php',
'App\\Services\\Ticket\\TicketService' => __DIR__ . '/../..' . '/app/Services/Ticket/TicketService.php',
'App\\Services\\Ticket\\TicketTypeService' => __DIR__ . '/../..' . '/app/Services/Ticket/TicketTypeService.php',
'App\\Services\\Ticket\\TicketWorkflowService' => __DIR__ . '/../..' . '/app/Services/Ticket/TicketWorkflowService.php',
'App\\Services\\Ticket\\TicketWorkflowValidator' => __DIR__ . '/../..' . '/app/Services/Ticket/TicketWorkflowValidator.php',
'App\\Services\\Topology\\TopologyService' => __DIR__ . '/../..' . '/app/Services/Topology/TopologyService.php',
'App\\Services\\User\\UserService' => __DIR__ . '/../..' . '/app/Services/User/UserService.php',
'App\\Services\\Voucher\\VoucherService' => __DIR__ . '/../..' . '/app/Services/Voucher/VoucherService.php',
'App\\Services\\Wallet\\WalletService' => __DIR__ . '/../..' . '/app/Services/Wallet/WalletService.php',
'App\\Services\\Wilayah\\WilayahService' => __DIR__ . '/../..' . '/app/Services/Wilayah/WilayahService.php',
'App\\Traits\\ApiResponse' => __DIR__ . '/../..' . '/app/Traits/ApiResponse.php',
'App\\Traits\\Auditable' => __DIR__ . '/../..' . '/app/Traits/Auditable.php',
'Attribute' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/Attribute.php',
@@ -2085,7 +2241,10 @@ class ComposerStaticInitd8f67b824a1bf31c8999ead558d9b485
'Database\\Factories\\UserFactory' => __DIR__ . '/../..' . '/database/factories/UserFactory.php',
'Database\\Seeders\\DatabaseSeeder' => __DIR__ . '/../..' . '/database/seeders/DatabaseSeeder.php',
'Database\\Seeders\\MenuListSeeder' => __DIR__ . '/../..' . '/database/seeders/MenuListSeeder.php',
'Database\\Seeders\\NotificationSeeder' => __DIR__ . '/../..' . '/database/seeders/NotificationSeeder.php',
'Database\\Seeders\\PaymentProviderSeeder' => __DIR__ . '/../..' . '/database/seeders/PaymentProviderSeeder.php',
'Database\\Seeders\\TicketTypeSeeder' => __DIR__ . '/../..' . '/database/seeders/TicketTypeSeeder.php',
'Database\\Seeders\\TopologySeeder' => __DIR__ . '/../..' . '/database/seeders/TopologySeeder.php',
'DateError' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateError.php',
'DateException' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateException.php',
'DateInvalidOperationException' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateInvalidOperationException.php',
@@ -7678,6 +7837,32 @@ class ComposerStaticInitd8f67b824a1bf31c8999ead558d9b485
'Ramsey\\Uuid\\Validator\\ValidatorInterface' => __DIR__ . '/..' . '/ramsey/uuid/src/Validator/ValidatorInterface.php',
'ReflectionConstant' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/ReflectionConstant.php',
'RoundingMode' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/RoundingMode.php',
'RouterOS\\APIConnector' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/APIConnector.php',
'RouterOS\\APILengthCoDec' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/APILengthCoDec.php',
'RouterOS\\Client' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Client.php',
'RouterOS\\Config' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Config.php',
'RouterOS\\Exceptions\\BadCredentialsException' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Exceptions/BadCredentialsException.php',
'RouterOS\\Exceptions\\ClientException' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Exceptions/ClientException.php',
'RouterOS\\Exceptions\\ConfigException' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Exceptions/ConfigException.php',
'RouterOS\\Exceptions\\ConnectException' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Exceptions/ConnectException.php',
'RouterOS\\Exceptions\\QueryException' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Exceptions/QueryException.php',
'RouterOS\\Exceptions\\StreamException' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Exceptions/StreamException.php',
'RouterOS\\Helpers\\ArrayHelper' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Helpers/ArrayHelper.php',
'RouterOS\\Helpers\\BinaryStringHelper' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Helpers/BinaryStringHelper.php',
'RouterOS\\Helpers\\TypeHelper' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Helpers/TypeHelper.php',
'RouterOS\\Interfaces\\ClientInterface' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Interfaces/ClientInterface.php',
'RouterOS\\Interfaces\\ConfigInterface' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Interfaces/ConfigInterface.php',
'RouterOS\\Interfaces\\QueryInterface' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Interfaces/QueryInterface.php',
'RouterOS\\Interfaces\\StreamInterface' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Interfaces/StreamInterface.php',
'RouterOS\\Laravel\\Facade' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Laravel/Facade.php',
'RouterOS\\Laravel\\ServiceProvider' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Laravel/ServiceProvider.php',
'RouterOS\\Laravel\\Wrapper' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Laravel/Wrapper.php',
'RouterOS\\Query' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Query.php',
'RouterOS\\ResponseIterator' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/ResponseIterator.php',
'RouterOS\\ShortsTrait' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/ShortsTrait.php',
'RouterOS\\SocketTrait' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/SocketTrait.php',
'RouterOS\\Streams\\ResourceStream' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Streams/ResourceStream.php',
'RouterOS\\Streams\\StringStream' => __DIR__ . '/..' . '/evilfreelancer/routeros-api-php/src/Streams/StringStream.php',
'SQLite3Exception' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/SQLite3Exception.php',
'SebastianBergmann\\CliParser\\AmbiguousOptionException' => __DIR__ . '/..' . '/sebastian/cli-parser/src/exceptions/AmbiguousOptionException.php',
'SebastianBergmann\\CliParser\\Exception' => __DIR__ . '/..' . '/sebastian/cli-parser/src/exceptions/Exception.php',
@@ -7919,6 +8104,7 @@ class ComposerStaticInitd8f67b824a1bf31c8999ead558d9b485
'Spatie\\LaravelPackageTools\\Exceptions\\InvalidPackage' => __DIR__ . '/..' . '/spatie/laravel-package-tools/src/Exceptions/InvalidPackage.php',
'Spatie\\LaravelPackageTools\\Package' => __DIR__ . '/..' . '/spatie/laravel-package-tools/src/Package.php',
'Spatie\\LaravelPackageTools\\PackageServiceProvider' => __DIR__ . '/..' . '/spatie/laravel-package-tools/src/PackageServiceProvider.php',
'Spatie\\Ssh\\Ssh' => __DIR__ . '/..' . '/spatie/ssh/src/Ssh.php',
'Stringable' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/Stringable.php',
'Symfony\\Component\\Clock\\Clock' => __DIR__ . '/..' . '/symfony/clock/Clock.php',
'Symfony\\Component\\Clock\\ClockAwareTrait' => __DIR__ . '/..' . '/symfony/clock/ClockAwareTrait.php',
@@ -8981,8 +9167,13 @@ class ComposerStaticInitd8f67b824a1bf31c8999ead558d9b485
'Termwind\\ValueObjects\\Node' => __DIR__ . '/..' . '/nunomaduro/termwind/src/ValueObjects/Node.php',
'Termwind\\ValueObjects\\Style' => __DIR__ . '/..' . '/nunomaduro/termwind/src/ValueObjects/Style.php',
'Termwind\\ValueObjects\\Styles' => __DIR__ . '/..' . '/nunomaduro/termwind/src/ValueObjects/Styles.php',
'Tests\\Feature\\BillingWorkflowTest' => __DIR__ . '/../..' . '/tests/Feature/BillingWorkflowTest.php',
'Tests\\Feature\\CustomerWorkflowTest' => __DIR__ . '/../..' . '/tests/Feature/CustomerWorkflowTest.php',
'Tests\\Feature\\ExampleTest' => __DIR__ . '/../..' . '/tests/Feature/ExampleTest.php',
'Tests\\Feature\\MenuAccessTest' => __DIR__ . '/../..' . '/tests/Feature/MenuAccessTest.php',
'Tests\\Feature\\NasResourceServiceTest' => __DIR__ . '/../..' . '/tests/Feature/NasResourceServiceTest.php',
'Tests\\Feature\\StoredFileApiTest' => __DIR__ . '/../..' . '/tests/Feature/StoredFileApiTest.php',
'Tests\\Feature\\WilayahApiTest' => __DIR__ . '/../..' . '/tests/Feature/WilayahApiTest.php',
'Tests\\TestCase' => __DIR__ . '/../..' . '/tests/TestCase.php',
'Tests\\Unit\\ExampleTest' => __DIR__ . '/../..' . '/tests/Unit/ExampleTest.php',
'TheSeer\\Tokenizer\\Exception' => __DIR__ . '/..' . '/theseer/tokenizer/src/Exception.php',
+144
View File
@@ -763,6 +763,88 @@
],
"install-path": "../egulias/email-validator"
},
{
"name": "evilfreelancer/routeros-api-php",
"version": "1.7.1",
"version_normalized": "1.7.1.0",
"source": {
"type": "git",
"url": "https://github.com/EvilFreelancer/routeros-api-php.git",
"reference": "5351c0c622e06c73e1c64caca453240328b486d4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/EvilFreelancer/routeros-api-php/zipball/5351c0c622e06c73e1c64caca453240328b486d4",
"reference": "5351c0c622e06c73e1c64caca453240328b486d4",
"shasum": ""
},
"require": {
"ext-sockets": "*",
"php": "^7.4|^8.0",
"spatie/ssh": "^1.8"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"orchestra/testbench": "^8.0",
"phpunit/phpunit": "^9.6",
"squizlabs/php_codesniffer": "^3.5"
},
"time": "2026-06-08T14:26:40+00:00",
"type": "library",
"extra": {
"laravel": {
"aliases": {
"RouterOS": "RouterOS\\Laravel\\Facade"
},
"providers": [
"RouterOS\\Laravel\\ServiceProvider"
]
}
},
"installation-source": "dist",
"autoload": {
"psr-4": {
"RouterOS\\": "./src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Rock",
"email": "paul@drteam.rocks",
"homepage": "http://drteam.rocks/",
"role": "Developer"
}
],
"description": "Modern Mikrotik RouterOS API PHP client for your applications (with Laravel support)",
"keywords": [
"PSR-4",
"facade",
"laravel",
"mikrotik",
"plugin",
"routeros",
"socket-client"
],
"support": {
"issues": "https://github.com/EvilFreelancer/routeros-api-php/issues",
"source": "https://github.com/EvilFreelancer/routeros-api-php/tree/1.7.1"
},
"funding": [
{
"url": "https://boosty.to/evilfreelancer",
"type": "custom"
},
{
"url": "https://pay.cloudtips.ru/p/937f48ac",
"type": "custom"
}
],
"install-path": "../evilfreelancer/routeros-api-php"
},
{
"name": "fakerphp/faker",
"version": "v1.24.1",
@@ -6262,6 +6344,68 @@
],
"install-path": "../spatie/laravel-package-tools"
},
{
"name": "spatie/ssh",
"version": "1.13.2",
"version_normalized": "1.13.2.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/ssh.git",
"reference": "3335ab102d1ad3e2cabc4e1079cd8b03f59bcd64"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/ssh/zipball/3335ab102d1ad3e2cabc4e1079cd8b03f59bcd64",
"reference": "3335ab102d1ad3e2cabc4e1079cd8b03f59bcd64",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0",
"symfony/process": "^4.4|^5.3|^6.0|^7.0|^8.0"
},
"require-dev": {
"pestphp/pest": "^1.22",
"spatie/pest-plugin-snapshots": "^1.1",
"symfony/var-dumper": "^5.3|^6.0|^7.0|^8.0"
},
"time": "2026-07-24T12:17:04+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-4": {
"Spatie\\Ssh\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "A lightweight package to execute commands over an SSH connection",
"homepage": "https://github.com/spatie/ssh",
"keywords": [
"spatie",
"ssh"
],
"support": {
"issues": "https://github.com/spatie/ssh/issues",
"source": "https://github.com/spatie/ssh/tree/1.13.2"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
}
],
"install-path": "../spatie/ssh"
},
{
"name": "staabm/side-effects-detector",
"version": "1.0.5",

Some files were not shown because too many files have changed in this diff Show More