From e7575ba1c0ce0ec42b2240162a63eb03a5ea7d51 Mon Sep 17 00:00:00 2001 From: Wian Drs Date: Fri, 7 Aug 2026 09:16:13 +0700 Subject: [PATCH] big update all menus --- .env.example | 4 + .../Commands/CheckMikrotikConnections.php | 33 +++ .../Customer/CustomerController.php | 97 ++++++++- app/Http/Controllers/Menu/MenuController.php | 11 +- .../Controllers/Nas/NasResourceController.php | 18 +- .../PackageProfileDeploymentController.php | 19 ++ .../Nas/RadiusClusterController.php | 44 ++++ .../Nas/ScriptPolicyController.php | 18 ++ .../Nas/TenantNasSettingController.php | 59 ++++++ .../Controllers/Tenant/TenantController.php | 42 ++++ .../Controllers/Ticket/TicketController.php | 85 +++++++- .../Ticket/TicketWorkflowController.php | 65 +++++- .../Warehouse/WarehouseBatchController.php | 33 +++ .../Warehouse/WarehouseController.php | 24 +++ .../Warehouse/WarehouseLocationController.php | 12 ++ .../Customer/ActivateCustomerRequest.php | 8 +- .../Customer/StoreCustomerRequest.php | 7 +- .../MenuGroup/SyncMenuGroupMenusRequest.php | 1 + .../Requests/Nas/IndexNasResourceRequest.php | 2 +- .../Requests/Nas/StoreNasResourceRequest.php | 65 +++++- .../Requests/Ticket/AssignTicketRequest.php | 3 +- .../Requests/Ticket/IndexTicketRequest.php | 3 + .../Requests/Ticket/PendingTicketRequest.php | 4 +- .../Requests/Ticket/ResolveTicketRequest.php | 4 +- .../Requests/Ticket/StartTicketRequest.php | 4 +- .../Requests/Ticket/StoreTicketRequest.php | 5 +- app/Http/Resources/Nas/NasResource.php | 18 ++ app/Http/Resources/Ticket/TicketResource.php | 17 ++ app/Jobs/CheckMikrotikConnection.php | 44 ++++ app/Jobs/ReconcileCustomerNasAccount.php | 15 ++ app/Models/Customer.php | 7 + app/Models/CustomerNasAccount.php | 42 ++++ app/Models/ItemBarcode.php | 4 + app/Models/ItemCode.php | 17 ++ app/Models/ItemMovement.php | 4 + app/Models/ItemStockLog.php | 4 + app/Models/MenuGroupMenu.php | 2 + app/Models/NasMikrotik.php | 19 +- app/Models/NasPackageProfile.php | 5 +- app/Models/NasPackageProfileTarget.php | 10 + app/Models/NasScriptPolicy.php | 10 + app/Models/NasScriptPolicyVersion.php | 8 + app/Models/RadiusCluster.php | 9 + app/Models/TechnicianConsumable.php | 4 +- app/Models/TechnicianMaterial.php | 4 +- app/Models/Tenant.php | 3 + app/Models/TenantInstallationSetting.php | 3 + app/Models/TenantNasSetting.php | 11 + app/Models/Ticket.php | 21 ++ app/Models/TicketAssignment.php | 3 + app/Models/TicketAttachment.php | 4 + app/Models/TicketMaterial.php | 5 +- app/Models/UserTenant.php | 1 + app/Models/Warehouse.php | 4 + app/Models/WarehouseItem.php | 4 + app/Services/Access/AccessService.php | 3 +- app/Services/Customer/CustomerService.php | 138 +++++++++++-- app/Services/Menu/MenuGroupService.php | 9 + .../CustomerAccountReconciliationService.php | 40 ++++ .../CustomerRouterOsProvisioningService.php | 160 +++++++++++++++ app/Services/Nas/MikrotikHealthService.php | 60 ++++++ app/Services/Nas/NasResourceService.php | 55 ++++- .../Nas/PackageProfileDeploymentService.php | 93 +++++++++ app/Services/Nas/RadiusHealthService.php | 72 +++++++ app/Services/Nas/RouterOsApiService.php | 185 +++++++++++++++++ app/Services/Nas/ScriptPolicyService.php | 29 +++ app/Services/Ticket/TicketService.php | 29 ++- app/Services/Ticket/TicketWorkflowService.php | 35 +++- .../Ticket/TicketWorkflowValidator.php | 8 +- composer.json | 1 + composer.lock | 140 ++++++++++++- config/nas.php | 9 + ..._000001_normalize_root_menu_sort_order.php | 48 +++++ ..._000002_reorder_root_menus_by_workflow.php | 57 ++++++ ...teros_monitoring_and_customer_accounts.php | 54 +++++ ...01_add_nas_health_schedule_and_details.php | 25 +++ ...profile_deployment_and_script_policies.php | 110 ++++++++++ ..._add_conditional_package_profile_types.php | 43 ++++ ...0_create_radius_control_plane_settings.php | 63 ++++++ ..._external_radius_endpoint_to_mikrotiks.php | 20 ++ ..._complete_radius_cluster_control_plane.php | 27 +++ ..._registration_with_sales_and_odp_ports.php | 33 +++ ...070000_use_sparse_odp_port_allocations.php | 24 +++ ..._remove_unallocated_topology_port_rows.php | 17 ++ ...create_customer_installation_workflows.php | 21 ++ ...tallation_workflow_with_tenant_setting.php | 3 + ...010000_secure_tenant_ocr_configuration.php | 20 ++ ...serial_number_to_customer_nas_accounts.php | 18 ++ ...reserve_invoices_when_customer_deleted.php | 30 +++ ...n_ticket_workflow_and_create_warehouse.php | 64 ++++++ ...warehouse_item_codes_and_barcode_stock.php | 75 +++++++ ..._05_060000_remove_warehouse_categories.php | 20 ++ ...ate_warehouses_and_batch_item_workflow.php | 47 +++++ database/seeders/MenuListSeeder.php | 40 ++-- routes/api.php | 92 ++++++++- routes/console.php | 11 + vendor/composer/autoload_classmap.php | 181 +++++++++++++++++ vendor/composer/autoload_psr4.php | 2 + vendor/composer/autoload_static.php | 191 ++++++++++++++++++ vendor/composer/installed.json | 144 +++++++++++++ vendor/composer/installed.php | 22 +- 101 files changed, 3451 insertions(+), 89 deletions(-) create mode 100644 app/Console/Commands/CheckMikrotikConnections.php create mode 100644 app/Http/Controllers/Nas/PackageProfileDeploymentController.php create mode 100644 app/Http/Controllers/Nas/RadiusClusterController.php create mode 100644 app/Http/Controllers/Nas/ScriptPolicyController.php create mode 100644 app/Http/Controllers/Nas/TenantNasSettingController.php create mode 100644 app/Http/Controllers/Warehouse/WarehouseBatchController.php create mode 100644 app/Http/Controllers/Warehouse/WarehouseController.php create mode 100644 app/Http/Controllers/Warehouse/WarehouseLocationController.php create mode 100644 app/Jobs/CheckMikrotikConnection.php create mode 100644 app/Jobs/ReconcileCustomerNasAccount.php create mode 100644 app/Models/CustomerNasAccount.php create mode 100644 app/Models/ItemBarcode.php create mode 100644 app/Models/ItemCode.php create mode 100644 app/Models/ItemMovement.php create mode 100644 app/Models/ItemStockLog.php create mode 100644 app/Models/NasPackageProfileTarget.php create mode 100644 app/Models/NasScriptPolicy.php create mode 100644 app/Models/NasScriptPolicyVersion.php create mode 100644 app/Models/RadiusCluster.php create mode 100644 app/Models/TenantInstallationSetting.php create mode 100644 app/Models/TenantNasSetting.php create mode 100644 app/Models/TicketAttachment.php create mode 100644 app/Models/Warehouse.php create mode 100644 app/Models/WarehouseItem.php create mode 100644 app/Services/Nas/CustomerAccountReconciliationService.php create mode 100644 app/Services/Nas/CustomerRouterOsProvisioningService.php create mode 100644 app/Services/Nas/MikrotikHealthService.php create mode 100644 app/Services/Nas/PackageProfileDeploymentService.php create mode 100644 app/Services/Nas/RadiusHealthService.php create mode 100644 app/Services/Nas/RouterOsApiService.php create mode 100644 app/Services/Nas/ScriptPolicyService.php create mode 100644 config/nas.php create mode 100644 database/migrations/2026_08_02_000001_normalize_root_menu_sort_order.php create mode 100644 database/migrations/2026_08_02_000002_reorder_root_menus_by_workflow.php create mode 100644 database/migrations/2026_08_02_010000_add_routeros_monitoring_and_customer_accounts.php create mode 100644 database/migrations/2026_08_03_000001_add_nas_health_schedule_and_details.php create mode 100644 database/migrations/2026_08_03_010000_create_nas_profile_deployment_and_script_policies.php create mode 100644 database/migrations/2026_08_03_020000_add_conditional_package_profile_types.php create mode 100644 database/migrations/2026_08_03_030000_create_radius_control_plane_settings.php create mode 100644 database/migrations/2026_08_03_040000_add_external_radius_endpoint_to_mikrotiks.php create mode 100644 database/migrations/2026_08_03_050000_complete_radius_cluster_control_plane.php create mode 100644 database/migrations/2026_08_03_060000_align_customer_registration_with_sales_and_odp_ports.php create mode 100644 database/migrations/2026_08_03_070000_use_sparse_odp_port_allocations.php create mode 100644 database/migrations/2026_08_03_071000_remove_unallocated_topology_port_rows.php create mode 100644 database/migrations/2026_08_03_080000_create_customer_installation_workflows.php create mode 100644 database/migrations/2026_08_05_000001_replace_installation_workflow_with_tenant_setting.php create mode 100644 database/migrations/2026_08_05_010000_secure_tenant_ocr_configuration.php create mode 100644 database/migrations/2026_08_05_020000_add_serial_number_to_customer_nas_accounts.php create mode 100644 database/migrations/2026_08_05_030000_preserve_invoices_when_customer_deleted.php create mode 100644 database/migrations/2026_08_05_040000_strengthen_ticket_workflow_and_create_warehouse.php create mode 100644 database/migrations/2026_08_05_050000_normalize_warehouse_item_codes_and_barcode_stock.php create mode 100644 database/migrations/2026_08_05_060000_remove_warehouse_categories.php create mode 100644 database/migrations/2026_08_05_070000_create_warehouses_and_batch_item_workflow.php diff --git a/.env.example b/.env.example index 0421a1f..375e049 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Console/Commands/CheckMikrotikConnections.php b/app/Console/Commands/CheckMikrotikConnections.php new file mode 100644 index 0000000..441104e --- /dev/null +++ b/app/Console/Commands/CheckMikrotikConnections.php @@ -0,0 +1,33 @@ +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; + } +} diff --git a/app/Http/Controllers/Customer/CustomerController.php b/app/Http/Controllers/Customer/CustomerController.php index daf02fa..5063dae 100644 --- a/app/Http/Controllers/Customer/CustomerController.php +++ b/app/Http/Controllers/Customer/CustomerController.php @@ -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); diff --git a/app/Http/Controllers/Menu/MenuController.php b/app/Http/Controllers/Menu/MenuController.php index cbc8126..4a901ef 100644 --- a/app/Http/Controllers/Menu/MenuController.php +++ b/app/Http/Controllers/Menu/MenuController.php @@ -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']); diff --git a/app/Http/Controllers/Nas/NasResourceController.php b/app/Http/Controllers/Nas/NasResourceController.php index 8da3761..3d90f8e 100644 --- a/app/Http/Controllers/Nas/NasResourceController.php +++ b/app/Http/Controllers/Nas/NasResourceController.php @@ -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']; diff --git a/app/Http/Controllers/Nas/PackageProfileDeploymentController.php b/app/Http/Controllers/Nas/PackageProfileDeploymentController.php new file mode 100644 index 0000000..871b4c4 --- /dev/null +++ b/app/Http/Controllers/Nas/PackageProfileDeploymentController.php @@ -0,0 +1,19 @@ +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); } +} diff --git a/app/Http/Controllers/Nas/RadiusClusterController.php b/app/Http/Controllers/Nas/RadiusClusterController.php new file mode 100644 index 0000000..1fdf4b8 --- /dev/null +++ b/app/Http/Controllers/Nas/RadiusClusterController.php @@ -0,0 +1,44 @@ +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.'); } +} diff --git a/app/Http/Controllers/Nas/ScriptPolicyController.php b/app/Http/Controllers/Nas/ScriptPolicyController.php new file mode 100644 index 0000000..4d23ecb --- /dev/null +++ b/app/Http/Controllers/Nas/ScriptPolicyController.php @@ -0,0 +1,18 @@ +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.'); } +} diff --git a/app/Http/Controllers/Nas/TenantNasSettingController.php b/app/Http/Controllers/Nas/TenantNasSettingController.php new file mode 100644 index 0000000..0e3459a --- /dev/null +++ b/app/Http/Controllers/Nas/TenantNasSettingController.php @@ -0,0 +1,59 @@ +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]; + } +} diff --git a/app/Http/Controllers/Tenant/TenantController.php b/app/Http/Controllers/Tenant/TenantController.php index 33c633b..bb9c25e 100644 --- a/app/Http/Controllers/Tenant/TenantController.php +++ b/app/Http/Controllers/Tenant/TenantController.php @@ -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'); + } } diff --git a/app/Http/Controllers/Ticket/TicketController.php b/app/Http/Controllers/Ticket/TicketController.php index 06fd169..931154a 100644 --- a/app/Http/Controllers/Ticket/TicketController.php +++ b/app/Http/Controllers/Ticket/TicketController.php @@ -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); + } } diff --git a/app/Http/Controllers/Ticket/TicketWorkflowController.php b/app/Http/Controllers/Ticket/TicketWorkflowController.php index e55ce9d..271a65b 100644 --- a/app/Http/Controllers/Ticket/TicketWorkflowController.php +++ b/app/Http/Controllers/Ticket/TicketWorkflowController.php @@ -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' ); } -} \ No newline at end of file + + 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); + } +} diff --git a/app/Http/Controllers/Warehouse/WarehouseBatchController.php b/app/Http/Controllers/Warehouse/WarehouseBatchController.php new file mode 100644 index 0000000..505eeb6 --- /dev/null +++ b/app/Http/Controllers/Warehouse/WarehouseBatchController.php @@ -0,0 +1,33 @@ +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);} +} diff --git a/app/Http/Controllers/Warehouse/WarehouseController.php b/app/Http/Controllers/Warehouse/WarehouseController.php new file mode 100644 index 0000000..c363b2e --- /dev/null +++ b/app/Http/Controllers/Warehouse/WarehouseController.php @@ -0,0 +1,24 @@ +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);} +} diff --git a/app/Http/Controllers/Warehouse/WarehouseLocationController.php b/app/Http/Controllers/Warehouse/WarehouseLocationController.php new file mode 100644 index 0000000..b5622f2 --- /dev/null +++ b/app/Http/Controllers/Warehouse/WarehouseLocationController.php @@ -0,0 +1,12 @@ +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);} +} diff --git a/app/Http/Requests/Customer/ActivateCustomerRequest.php b/app/Http/Requests/Customer/ActivateCustomerRequest.php index 62fadcc..4b65bbc 100644 --- a/app/Http/Requests/Customer/ActivateCustomerRequest.php +++ b/app/Http/Requests/Customer/ActivateCustomerRequest.php @@ -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'], ]; } } diff --git a/app/Http/Requests/Customer/StoreCustomerRequest.php b/app/Http/Requests/Customer/StoreCustomerRequest.php index 5c70a0a..f53325e 100644 --- a/app/Http/Requests/Customer/StoreCustomerRequest.php +++ b/app/Http/Requests/Customer/StoreCustomerRequest.php @@ -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'], diff --git a/app/Http/Requests/MenuGroup/SyncMenuGroupMenusRequest.php b/app/Http/Requests/MenuGroup/SyncMenuGroupMenusRequest.php index 10dc8e6..56786d9 100644 --- a/app/Http/Requests/MenuGroup/SyncMenuGroupMenusRequest.php +++ b/app/Http/Requests/MenuGroup/SyncMenuGroupMenusRequest.php @@ -49,6 +49,7 @@ class SyncMenuGroupMenusRequest extends FormRequest 'nullable', 'boolean', ], + 'menus.*.can_activate' => ['nullable','boolean'], 'menus.*.can_export' => [ 'nullable', 'boolean', diff --git a/app/Http/Requests/Nas/IndexNasResourceRequest.php b/app/Http/Requests/Nas/IndexNasResourceRequest.php index b9e295f..e46ba84 100644 --- a/app/Http/Requests/Nas/IndexNasResourceRequest.php +++ b/app/Http/Requests/Nas/IndexNasResourceRequest.php @@ -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'])], diff --git a/app/Http/Requests/Nas/StoreNasResourceRequest.php b/app/Http/Requests/Nas/StoreNasResourceRequest.php index d020c64..cb34c7d 100644 --- a/app/Http/Requests/Nas/StoreNasResourceRequest.php +++ b/app/Http/Requests/Nas/StoreNasResourceRequest.php @@ -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.'); + } + }); + } } diff --git a/app/Http/Requests/Ticket/AssignTicketRequest.php b/app/Http/Requests/Ticket/AssignTicketRequest.php index aed707a..93f3ace 100644 --- a/app/Http/Requests/Ticket/AssignTicketRequest.php +++ b/app/Http/Requests/Ticket/AssignTicketRequest.php @@ -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.*', ]; } } diff --git a/app/Http/Requests/Ticket/IndexTicketRequest.php b/app/Http/Requests/Ticket/IndexTicketRequest.php index 705cbe8..03f7f76 100644 --- a/app/Http/Requests/Ticket/IndexTicketRequest.php +++ b/app/Http/Requests/Ticket/IndexTicketRequest.php @@ -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'], ]; } } diff --git a/app/Http/Requests/Ticket/PendingTicketRequest.php b/app/Http/Requests/Ticket/PendingTicketRequest.php index eed1563..902de76 100644 --- a/app/Http/Requests/Ticket/PendingTicketRequest.php +++ b/app/Http/Requests/Ticket/PendingTicketRequest.php @@ -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'], ]; } } diff --git a/app/Http/Requests/Ticket/ResolveTicketRequest.php b/app/Http/Requests/Ticket/ResolveTicketRequest.php index b5140c8..7ef1fbe 100644 --- a/app/Http/Requests/Ticket/ResolveTicketRequest.php +++ b/app/Http/Requests/Ticket/ResolveTicketRequest.php @@ -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'], ]; } } diff --git a/app/Http/Requests/Ticket/StartTicketRequest.php b/app/Http/Requests/Ticket/StartTicketRequest.php index 3e75dae..9f3f80e 100644 --- a/app/Http/Requests/Ticket/StartTicketRequest.php +++ b/app/Http/Requests/Ticket/StartTicketRequest.php @@ -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'], ]; } } diff --git a/app/Http/Requests/Ticket/StoreTicketRequest.php b/app/Http/Requests/Ticket/StoreTicketRequest.php index 1abb88f..3d1bcfe 100644 --- a/app/Http/Requests/Ticket/StoreTicketRequest.php +++ b/app/Http/Requests/Ticket/StoreTicketRequest.php @@ -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' diff --git a/app/Http/Resources/Nas/NasResource.php b/app/Http/Resources/Nas/NasResource.php index 1bc984c..dce60a6 100644 --- a/app/Http/Resources/Nas/NasResource.php +++ b/app/Http/Resources/Nas/NasResource.php @@ -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', diff --git a/app/Http/Resources/Ticket/TicketResource.php b/app/Http/Resources/Ticket/TicketResource.php index 27a9968..2edb95b 100644 --- a/app/Http/Resources/Ticket/TicketResource.php +++ b/app/Http/Resources/Ticket/TicketResource.php @@ -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'), ]; } } diff --git a/app/Jobs/CheckMikrotikConnection.php b/app/Jobs/CheckMikrotikConnection.php new file mode 100644 index 0000000..c71d679 --- /dev/null +++ b/app/Jobs/CheckMikrotikConnection.php @@ -0,0 +1,44 @@ +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. + } + } +} diff --git a/app/Jobs/ReconcileCustomerNasAccount.php b/app/Jobs/ReconcileCustomerNasAccount.php new file mode 100644 index 0000000..b6f2b92 --- /dev/null +++ b/app/Jobs/ReconcileCustomerNasAccount.php @@ -0,0 +1,15 @@ +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()]);} } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index f6e64ba..c13e2f9 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -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'); diff --git a/app/Models/CustomerNasAccount.php b/app/Models/CustomerNasAccount.php new file mode 100644 index 0000000..44e92db --- /dev/null +++ b/app/Models/CustomerNasAccount.php @@ -0,0 +1,42 @@ + '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'); + } +} diff --git a/app/Models/ItemBarcode.php b/app/Models/ItemBarcode.php new file mode 100644 index 0000000..60c858f --- /dev/null +++ b/app/Models/ItemBarcode.php @@ -0,0 +1,4 @@ +'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');} } diff --git a/app/Models/ItemCode.php b/app/Models/ItemCode.php new file mode 100644 index 0000000..4c8fb57 --- /dev/null +++ b/app/Models/ItemCode.php @@ -0,0 +1,17 @@ + 'boolean', 'minimum_stock' => 'decimal:2', 'is_active' => 'boolean']; + } + + public function items() { return $this->hasMany(WarehouseItem::class); } +} diff --git a/app/Models/ItemMovement.php b/app/Models/ItemMovement.php new file mode 100644 index 0000000..6aa47e1 --- /dev/null +++ b/app/Models/ItemMovement.php @@ -0,0 +1,4 @@ +'decimal:2'];} } diff --git a/app/Models/ItemStockLog.php b/app/Models/ItemStockLog.php new file mode 100644 index 0000000..c5a7287 --- /dev/null +++ b/app/Models/ItemStockLog.php @@ -0,0 +1,4 @@ +'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'];} } diff --git a/app/Models/MenuGroupMenu.php b/app/Models/MenuGroupMenu.php index b5e4263..2da4b24 100644 --- a/app/Models/MenuGroupMenu.php +++ b/app/Models/MenuGroupMenu.php @@ -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', ]; diff --git a/app/Models/NasMikrotik.php b/app/Models/NasMikrotik.php index b0eb353..3be4a2f 100644 --- a/app/Models/NasMikrotik.php +++ b/app/Models/NasMikrotik.php @@ -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'); + } } diff --git a/app/Models/NasPackageProfile.php b/app/Models/NasPackageProfile.php index 982fac0..dee2dd9 100644 --- a/app/Models/NasPackageProfile.php +++ b/app/Models/NasPackageProfile.php @@ -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']); } } diff --git a/app/Models/NasPackageProfileTarget.php b/app/Models/NasPackageProfileTarget.php new file mode 100644 index 0000000..a3e6bb6 --- /dev/null +++ b/app/Models/NasPackageProfileTarget.php @@ -0,0 +1,10 @@ + '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'); } +} diff --git a/app/Models/NasScriptPolicy.php b/app/Models/NasScriptPolicy.php new file mode 100644 index 0000000..d3f5741 --- /dev/null +++ b/app/Models/NasScriptPolicy.php @@ -0,0 +1,10 @@ + 'array', 'enabled' => 'boolean']; } + public function versions() { return $this->hasMany(NasScriptPolicyVersion::class); } + public function latestVersion() { return $this->hasOne(NasScriptPolicyVersion::class)->ofMany('version', 'max'); } +} diff --git a/app/Models/NasScriptPolicyVersion.php b/app/Models/NasScriptPolicyVersion.php new file mode 100644 index 0000000..d8c8f5c --- /dev/null +++ b/app/Models/NasScriptPolicyVersion.php @@ -0,0 +1,8 @@ +belongsTo(NasScriptPolicy::class, 'nas_script_policy_id'); } +} diff --git a/app/Models/RadiusCluster.php b/app/Models/RadiusCluster.php new file mode 100644 index 0000000..8c2a816 --- /dev/null +++ b/app/Models/RadiusCluster.php @@ -0,0 +1,9 @@ +'array','accounting_endpoints'=>'array','coa_endpoints'=>'array','provisioning_api_token'=>'encrypted','last_checked_at'=>'datetime']; } +} diff --git a/app/Models/TechnicianConsumable.php b/app/Models/TechnicianConsumable.php index 50adf91..5b8c3a4 100644 --- a/app/Models/TechnicianConsumable.php +++ b/app/Models/TechnicianConsumable.php @@ -21,4 +21,6 @@ class TechnicianConsumable extends Model 'assigned_at', 'assigned_by' ]; -} \ No newline at end of file + + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/TechnicianMaterial.php b/app/Models/TechnicianMaterial.php index 721ac8b..11a066c 100644 --- a/app/Models/TechnicianMaterial.php +++ b/app/Models/TechnicianMaterial.php @@ -18,4 +18,6 @@ class TechnicianMaterial extends Model 'assigned_at', 'assigned_by', ]; -} \ No newline at end of file + + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 1069740..b2cc498 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -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); } } diff --git a/app/Models/TenantInstallationSetting.php b/app/Models/TenantInstallationSetting.php new file mode 100644 index 0000000..b81c06f --- /dev/null +++ b/app/Models/TenantInstallationSetting.php @@ -0,0 +1,3 @@ +'boolean','ocr_api_key'=>'encrypted'];}public function tenant(){return $this->belongsTo(Tenant::class);}} diff --git a/app/Models/TenantNasSetting.php b/app/Models/TenantNasSetting.php new file mode 100644 index 0000000..95dc81a --- /dev/null +++ b/app/Models/TenantNasSetting.php @@ -0,0 +1,11 @@ +'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); } +} diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index e777d82..36ea40d 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -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'); } } diff --git a/app/Models/TicketAssignment.php b/app/Models/TicketAssignment.php index 1f086bd..63f756c 100644 --- a/app/Models/TicketAssignment.php +++ b/app/Models/TicketAssignment.php @@ -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); } } diff --git a/app/Models/TicketAttachment.php b/app/Models/TicketAttachment.php new file mode 100644 index 0000000..84cd32a --- /dev/null +++ b/app/Models/TicketAttachment.php @@ -0,0 +1,4 @@ +belongsTo(StoredFile::class,'stored_file_id');} public function uploader(){return $this->belongsTo(User::class,'uploaded_by');} } diff --git a/app/Models/TicketMaterial.php b/app/Models/TicketMaterial.php index b05baba..be8067e 100644 --- a/app/Models/TicketMaterial.php +++ b/app/Models/TicketMaterial.php @@ -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');} } diff --git a/app/Models/UserTenant.php b/app/Models/UserTenant.php index 3663644..c3208d5 100644 --- a/app/Models/UserTenant.php +++ b/app/Models/UserTenant.php @@ -12,6 +12,7 @@ class UserTenant extends Model 'user_id', 'tenant_id', 'role', + 'department', 'is_default', ]; diff --git a/app/Models/Warehouse.php b/app/Models/Warehouse.php new file mode 100644 index 0000000..33bd0dd --- /dev/null +++ b/app/Models/Warehouse.php @@ -0,0 +1,4 @@ +'boolean'];} public function items(){return $this->hasMany(WarehouseItem::class);} } diff --git a/app/Models/WarehouseItem.php b/app/Models/WarehouseItem.php new file mode 100644 index 0000000..db3edc7 --- /dev/null +++ b/app/Models/WarehouseItem.php @@ -0,0 +1,4 @@ +'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();} } diff --git a/app/Services/Access/AccessService.php b/app/Services/Access/AccessService.php index 83f6156..66fccdf 100644 --- a/app/Services/Access/AccessService.php +++ b/app/Services/Access/AccessService.php @@ -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', ]; diff --git a/app/Services/Customer/CustomerService.php b/app/Services/Customer/CustomerService.php index bdaf16d..863904a 100644 --- a/app/Services/Customer/CustomerService.php +++ b/app/Services/Customer/CustomerService.php @@ -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 diff --git a/app/Services/Menu/MenuGroupService.php b/app/Services/Menu/MenuGroupService.php index 778528c..09ad669 100644 --- a/app/Services/Menu/MenuGroupService.php +++ b/app/Services/Menu/MenuGroupService.php @@ -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(); diff --git a/app/Services/Nas/CustomerAccountReconciliationService.php b/app/Services/Nas/CustomerAccountReconciliationService.php new file mode 100644 index 0000000..ae27e0a --- /dev/null +++ b/app/Services/Nas/CustomerAccountReconciliationService.php @@ -0,0 +1,40 @@ +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}; } +} diff --git a/app/Services/Nas/CustomerRouterOsProvisioningService.php b/app/Services/Nas/CustomerRouterOsProvisioningService.php new file mode 100644 index 0000000..afd6ce4 --- /dev/null +++ b/app/Services/Nas/CustomerRouterOsProvisioningService.php @@ -0,0 +1,160 @@ +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, + }; + } +} diff --git a/app/Services/Nas/MikrotikHealthService.php b/app/Services/Nas/MikrotikHealthService.php new file mode 100644 index 0000000..1a7a014 --- /dev/null +++ b/app/Services/Nas/MikrotikHealthService.php @@ -0,0 +1,60 @@ +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; + } + } +} diff --git a/app/Services/Nas/NasResourceService.php b/app/Services/Nas/NasResourceService.php index c0c420e..46f9f9c 100644 --- a/app/Services/Nas/NasResourceService.php +++ b/app/Services/Nas/NasResourceService.php @@ -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(); diff --git a/app/Services/Nas/PackageProfileDeploymentService.php b/app/Services/Nas/PackageProfileDeploymentService.php new file mode 100644 index 0000000..27e2b2c --- /dev/null +++ b/app/Services/Nas/PackageProfileDeploymentService.php @@ -0,0 +1,93 @@ +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, + ]); + } +} diff --git a/app/Services/Nas/RadiusHealthService.php b/app/Services/Nas/RadiusHealthService.php new file mode 100644 index 0000000..21ece43 --- /dev/null +++ b/app/Services/Nas/RadiusHealthService.php @@ -0,0 +1,72 @@ +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); + } + } +} diff --git a/app/Services/Nas/RouterOsApiService.php b/app/Services/Nas/RouterOsApiService.php new file mode 100644 index 0000000..c4a72d4 --- /dev/null +++ b/app/Services/Nas/RouterOsApiService.php @@ -0,0 +1,185 @@ +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}"; + } +} diff --git a/app/Services/Nas/ScriptPolicyService.php b/app/Services/Nas/ScriptPolicyService.php new file mode 100644 index 0000000..13f0ec2 --- /dev/null +++ b/app/Services/Nas/ScriptPolicyService.php @@ -0,0 +1,29 @@ +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']); + }); + } +} diff --git a/app/Services/Ticket/TicketService.php b/app/Services/Ticket/TicketService.php index 880ae09..5ee3b8b 100644 --- a/app/Services/Ticket/TicketService.php +++ b/app/Services/Ticket/TicketService.php @@ -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 ); } -} \ No newline at end of file + + public function generateNo(): string { return $this->generateTicketNo(); } +} diff --git a/app/Services/Ticket/TicketWorkflowService.php b/app/Services/Ticket/TicketWorkflowService.php index a00473e..ca8c67a 100644 --- a/app/Services/Ticket/TicketWorkflowService.php +++ b/app/Services/Ticket/TicketWorkflowService.php @@ -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() ]); } -} \ No newline at end of file + + 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]);} + } +} diff --git a/app/Services/Ticket/TicketWorkflowValidator.php b/app/Services/Ticket/TicketWorkflowValidator.php index c461fd7..3030d56 100644 --- a/app/Services/Ticket/TicketWorkflowValidator.php +++ b/app/Services/Ticket/TicketWorkflowValidator.php @@ -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."]); } } -} \ No newline at end of file +} diff --git a/composer.json b/composer.json index 0218501..05d2fd4 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 28fa52b..4798b4b 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/nas.php b/config/nas.php new file mode 100644 index 0000000..60707e3 --- /dev/null +++ b/config/nas.php @@ -0,0 +1,9 @@ + [ + '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), + ], +]; diff --git a/database/migrations/2026_08_02_000001_normalize_root_menu_sort_order.php b/database/migrations/2026_08_02_000001_normalize_root_menu_sort_order.php new file mode 100644 index 0000000..2019c75 --- /dev/null +++ b/database/migrations/2026_08_02_000001_normalize_root_menu_sort_order.php @@ -0,0 +1,48 @@ + 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]); + } + } +}; diff --git a/database/migrations/2026_08_02_000002_reorder_root_menus_by_workflow.php b/database/migrations/2026_08_02_000002_reorder_root_menus_by_workflow.php new file mode 100644 index 0000000..a284e79 --- /dev/null +++ b/database/migrations/2026_08_02_000002_reorder_root_menus_by_workflow.php @@ -0,0 +1,57 @@ + 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]); + } + } +}; diff --git a/database/migrations/2026_08_02_010000_add_routeros_monitoring_and_customer_accounts.php b/database/migrations/2026_08_02_010000_add_routeros_monitoring_and_customer_accounts.php new file mode 100644 index 0000000..7debee7 --- /dev/null +++ b/database/migrations/2026_08_02_010000_add_routeros_monitoring_and_customer_accounts.php @@ -0,0 +1,54 @@ +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', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_03_000001_add_nas_health_schedule_and_details.php b/database/migrations/2026_08_03_000001_add_nas_health_schedule_and_details.php new file mode 100644 index 0000000..4921e83 --- /dev/null +++ b/database/migrations/2026_08_03_000001_add_nas_health_schedule_and_details.php @@ -0,0 +1,25 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_08_03_010000_create_nas_profile_deployment_and_script_policies.php b/database/migrations/2026_08_03_010000_create_nas_profile_deployment_and_script_policies.php new file mode 100644 index 0000000..7a6a3ae --- /dev/null +++ b/database/migrations/2026_08_03_010000_create_nas_profile_deployment_and_script_policies.php @@ -0,0 +1,110 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_08_03_020000_add_conditional_package_profile_types.php b/database/migrations/2026_08_03_020000_add_conditional_package_profile_types.php new file mode 100644 index 0000000..9be40a3 --- /dev/null +++ b/database/migrations/2026_08_03_020000_add_conditional_package_profile_types.php @@ -0,0 +1,43 @@ +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(); + }); + } +}; diff --git a/database/migrations/2026_08_03_030000_create_radius_control_plane_settings.php b/database/migrations/2026_08_03_030000_create_radius_control_plane_settings.php new file mode 100644 index 0000000..1fb2553 --- /dev/null +++ b/database/migrations/2026_08_03_030000_create_radius_control_plane_settings.php @@ -0,0 +1,63 @@ +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'); + } +}; diff --git a/database/migrations/2026_08_03_040000_add_external_radius_endpoint_to_mikrotiks.php b/database/migrations/2026_08_03_040000_add_external_radius_endpoint_to_mikrotiks.php new file mode 100644 index 0000000..3268c57 --- /dev/null +++ b/database/migrations/2026_08_03_040000_add_external_radius_endpoint_to_mikrotiks.php @@ -0,0 +1,20 @@ +string('radius_host')->nullable()->after('nas_ip_address'); + }); + } + + public function down(): void + { + Schema::table('nas_mikrotiks', fn (Blueprint $table) => $table->dropColumn('radius_host')); + } +}; diff --git a/database/migrations/2026_08_03_050000_complete_radius_cluster_control_plane.php b/database/migrations/2026_08_03_050000_complete_radius_cluster_control_plane.php new file mode 100644 index 0000000..b56fc98 --- /dev/null +++ b/database/migrations/2026_08_03_050000_complete_radius_cluster_control_plane.php @@ -0,0 +1,27 @@ +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'])); + } +}; diff --git a/database/migrations/2026_08_03_060000_align_customer_registration_with_sales_and_odp_ports.php b/database/migrations/2026_08_03_060000_align_customer_registration_with_sales_and_odp_ports.php new file mode 100644 index 0000000..584bd98 --- /dev/null +++ b/database/migrations/2026_08_03_060000_align_customer_registration_with_sales_and_odp_ports.php @@ -0,0 +1,33 @@ +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(); + }); + } +}; diff --git a/database/migrations/2026_08_03_070000_use_sparse_odp_port_allocations.php b/database/migrations/2026_08_03_070000_use_sparse_odp_port_allocations.php new file mode 100644 index 0000000..735a69e --- /dev/null +++ b/database/migrations/2026_08_03_070000_use_sparse_odp_port_allocations.php @@ -0,0 +1,24 @@ + $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'); + }); + } +}; diff --git a/database/migrations/2026_08_03_071000_remove_unallocated_topology_port_rows.php b/database/migrations/2026_08_03_071000_remove_unallocated_topology_port_rows.php new file mode 100644 index 0000000..d79a7cb --- /dev/null +++ b/database/migrations/2026_08_03_071000_remove_unallocated_topology_port_rows.php @@ -0,0 +1,17 @@ +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');} +}; diff --git a/database/migrations/2026_08_05_000001_replace_installation_workflow_with_tenant_setting.php b/database/migrations/2026_08_05_000001_replace_installation_workflow_with_tenant_setting.php new file mode 100644 index 0000000..37abc89 --- /dev/null +++ b/database/migrations/2026_08_05_000001_replace_installation_workflow_with_tenant_setting.php @@ -0,0 +1,3 @@ +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'));}}; diff --git a/database/migrations/2026_08_05_010000_secure_tenant_ocr_configuration.php b/database/migrations/2026_08_05_010000_secure_tenant_ocr_configuration.php new file mode 100644 index 0000000..3f0c6d5 --- /dev/null +++ b/database/migrations/2026_08_05_010000_secure_tenant_ocr_configuration.php @@ -0,0 +1,20 @@ +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')); + } +}; diff --git a/database/migrations/2026_08_05_020000_add_serial_number_to_customer_nas_accounts.php b/database/migrations/2026_08_05_020000_add_serial_number_to_customer_nas_accounts.php new file mode 100644 index 0000000..2e528da --- /dev/null +++ b/database/migrations/2026_08_05_020000_add_serial_number_to_customer_nas_accounts.php @@ -0,0 +1,18 @@ + $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')); + } +}; diff --git a/database/migrations/2026_08_05_030000_preserve_invoices_when_customer_deleted.php b/database/migrations/2026_08_05_030000_preserve_invoices_when_customer_deleted.php new file mode 100644 index 0000000..5652042 --- /dev/null +++ b/database/migrations/2026_08_05_030000_preserve_invoices_when_customer_deleted.php @@ -0,0 +1,30 @@ +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(); + }); + } +}; diff --git a/database/migrations/2026_08_05_040000_strengthen_ticket_workflow_and_create_warehouse.php b/database/migrations/2026_08_05_040000_strengthen_ticket_workflow_and_create_warehouse.php new file mode 100644 index 0000000..018c5d6 --- /dev/null +++ b/database/migrations/2026_08_05_040000_strengthen_ticket_workflow_and_create_warehouse.php @@ -0,0 +1,64 @@ +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']);}); + } +}; diff --git a/database/migrations/2026_08_05_050000_normalize_warehouse_item_codes_and_barcode_stock.php b/database/migrations/2026_08_05_050000_normalize_warehouse_item_codes_and_barcode_stock.php new file mode 100644 index 0000000..adf42b8 --- /dev/null +++ b/database/migrations/2026_08_05_050000_normalize_warehouse_item_codes_and_barcode_stock.php @@ -0,0 +1,75 @@ +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'); + } +}; diff --git a/database/migrations/2026_08_05_060000_remove_warehouse_categories.php b/database/migrations/2026_08_05_060000_remove_warehouse_categories.php new file mode 100644 index 0000000..eeca8a0 --- /dev/null +++ b/database/migrations/2026_08_05_060000_remove_warehouse_categories.php @@ -0,0 +1,20 @@ + $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()); + } +}; diff --git a/database/migrations/2026_08_05_070000_create_warehouses_and_batch_item_workflow.php b/database/migrations/2026_08_05_070000_create_warehouses_and_batch_item_workflow.php new file mode 100644 index 0000000..711f9fa --- /dev/null +++ b/database/migrations/2026_08_05_070000_create_warehouses_and_batch_item_workflow.php @@ -0,0 +1,47 @@ +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'); + } +}; diff --git a/database/seeders/MenuListSeeder.php b/database/seeders/MenuListSeeder.php index 67b7985..010a546 100644 --- a/database/seeders/MenuListSeeder.php +++ b/database/seeders/MenuListSeeder.php @@ -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, ] ); diff --git a/routes/api.php b/routes/api.php index c799f43..2c89cc2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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', diff --git a/routes/console.php b/routes/console.php index 685f083..12be24f 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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(); diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php index fb4aded..2bab727 100644 --- a/vendor/composer/autoload_classmap.php +++ b/vendor/composer/autoload_classmap.php @@ -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', diff --git a/vendor/composer/autoload_psr4.php b/vendor/composer/autoload_psr4.php index 25c95f0..0f5a278 100644 --- a/vendor/composer/autoload_psr4.php +++ b/vendor/composer/autoload_psr4.php @@ -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'), diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php index d0e9ac9..ff38caa 100644 --- a/vendor/composer/autoload_static.php +++ b/vendor/composer/autoload_static.php @@ -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', diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json index e91bdd1..bad97db 100644 --- a/vendor/composer/installed.json +++ b/vendor/composer/installed.json @@ -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", diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index c09f7a8..81429b1 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -3,7 +3,7 @@ 'name' => 'laravel/laravel', 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => '2b3592c4c287719f1bacef74685f371b9cdc185f', + 'reference' => '75342dc7b744c8dd46d9d0f20b34c84650bca30e', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -112,6 +112,15 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'evilfreelancer/routeros-api-php' => array( + 'pretty_version' => '1.7.1', + 'version' => '1.7.1.0', + 'reference' => '5351c0c622e06c73e1c64caca453240328b486d4', + 'type' => 'library', + 'install_path' => __DIR__ . '/../evilfreelancer/routeros-api-php', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'fakerphp/faker' => array( 'pretty_version' => 'v1.24.1', 'version' => '1.24.1.0', @@ -427,7 +436,7 @@ 'laravel/laravel' => array( 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => '2b3592c4c287719f1bacef74685f371b9cdc185f', + 'reference' => '75342dc7b744c8dd46d9d0f20b34c84650bca30e', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -1067,6 +1076,15 @@ 0 => '*', ), ), + 'spatie/ssh' => array( + 'pretty_version' => '1.13.2', + 'version' => '1.13.2.0', + 'reference' => '3335ab102d1ad3e2cabc4e1079cd8b03f59bcd64', + 'type' => 'library', + 'install_path' => __DIR__ . '/../spatie/ssh', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'staabm/side-effects-detector' => array( 'pretty_version' => '1.0.5', 'version' => '1.0.5.0',