forked from admin/services_core
big update all menus
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\NasMikrotik;
|
||||
use App\Jobs\CheckMikrotikConnection;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckMikrotikConnections extends Command
|
||||
{
|
||||
protected $signature = 'nas:check-mikrotiks {--id=* : ID Mikrotik tertentu}';
|
||||
|
||||
protected $description = 'Mengantrekan pemeriksaan koneksi NAS yang sudah jatuh tempo';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$queued = 0;
|
||||
NasMikrotik::query()
|
||||
->where('status', 'active')
|
||||
->where(fn ($query) => $query->whereNull('next_health_check_at')->orWhere('next_health_check_at', '<=', now()))
|
||||
->when($this->option('id'), fn ($query) => $query->whereIn('id', $this->option('id')))
|
||||
->select(['id'])
|
||||
->chunkById(100, function ($devices) use (&$queued) {
|
||||
foreach ($devices as $index => $device) {
|
||||
CheckMikrotikConnection::dispatch($device->id)->delay(now()->addSeconds($index % 60));
|
||||
$queued++;
|
||||
}
|
||||
});
|
||||
$this->info("{$queued} pemeriksaan NAS masuk antrean nas-health.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use App\Http\Requests\Customer\UpdateCustomerRequest;
|
||||
use App\Http\Resources\Customer\CustomerResource;
|
||||
use App\Services\Customer\CustomerService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CustomerController extends ApiController
|
||||
{
|
||||
@@ -32,6 +34,60 @@ class CustomerController extends ApiController
|
||||
);
|
||||
}
|
||||
|
||||
public function odpPorts(Request $request, int $node)
|
||||
{
|
||||
$data=$request->validate(['customer_id'=>['nullable','integer','exists:customers,id']]);
|
||||
return $this->success($this->service->availableOdpPorts($node,$request->user(),$this->tenantId($request),$data['customer_id'] ?? null),'Port ODP tersedia berhasil diambil');
|
||||
}
|
||||
|
||||
public function ocrKtp(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'image' => ['required', 'image', 'mimes:jpeg,jpg,png,webp', 'max:5120'],
|
||||
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
|
||||
]);
|
||||
$tenantId = $request->user()->isMasterAdmin()
|
||||
? ($data['tenant_id'] ?? $request->attributes->get('tenant_id'))
|
||||
: $request->attributes->get('tenant_id');
|
||||
abort_unless($tenantId, 422, 'Tenant wajib dipilih sebelum menjalankan OCR.');
|
||||
if (! $request->user()->isMasterAdmin()) {
|
||||
abort_unless($request->user()->userTenants()->where('tenant_id', $tenantId)->exists(), 403);
|
||||
}
|
||||
$setting = \App\Models\TenantInstallationSetting::where('tenant_id', $tenantId)->first();
|
||||
abort_unless($setting?->ktp_ocr_enabled, 422, 'OCR KTP belum diaktifkan untuk tenant ini.');
|
||||
abort_unless($setting->ocr_provider && $setting->ocr_api_key, 422, 'Provider atau API key OCR belum dikonfigurasi.');
|
||||
|
||||
$file = $data['image'];
|
||||
$mime = $file->getMimeType();
|
||||
$base64 = base64_encode(file_get_contents($file->getRealPath()));
|
||||
$prompt = 'Baca KTP Indonesia ini. Kembalikan hanya JSON valid tanpa markdown dengan field: nik, nama, alamat, rt_rw, kelurahan_desa, kecamatan, kabupaten_kota, provinsi. Gunakan null bila tidak terbaca.';
|
||||
|
||||
if ($setting->ocr_provider === 'openai') {
|
||||
$response = Http::withToken($setting->ocr_api_key)->timeout(60)->post('https://api.openai.com/v1/chat/completions', [
|
||||
'model' => 'gpt-4.1-mini',
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
'messages' => [['role' => 'user', 'content' => [
|
||||
['type' => 'text', 'text' => $prompt],
|
||||
['type' => 'image_url', 'image_url' => ['url' => "data:{$mime};base64,{$base64}"]],
|
||||
]]],
|
||||
]);
|
||||
$content = $response->successful() ? $response->json('choices.0.message.content') : null;
|
||||
} else {
|
||||
$response = Http::timeout(60)->post('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key='.urlencode($setting->ocr_api_key), [
|
||||
'contents' => [['parts' => [['text' => $prompt], ['inline_data' => ['mime_type' => $mime, 'data' => $base64]]]]],
|
||||
'generationConfig' => ['responseMimeType' => 'application/json'],
|
||||
]);
|
||||
$content = $response->successful() ? $response->json('candidates.0.content.parts.0.text') : null;
|
||||
}
|
||||
if (! $response->successful() || ! $content) {
|
||||
throw ValidationException::withMessages(['image' => 'Provider AI gagal membaca KTP. Periksa API key, kuota, dan kualitas gambar.']);
|
||||
}
|
||||
$result = json_decode(trim(str_replace(['```json', '```'], '', $content)), true);
|
||||
if (! is_array($result)) throw ValidationException::withMessages(['image' => 'Respons OCR tidak dapat diproses.']);
|
||||
|
||||
return $this->success($result, 'KTP berhasil dibaca');
|
||||
}
|
||||
|
||||
public function store(StoreCustomerRequest $request)
|
||||
{
|
||||
return $this->created(
|
||||
@@ -59,12 +115,41 @@ class CustomerController extends ApiController
|
||||
);
|
||||
}
|
||||
|
||||
public function prepareActivation(UpdateCustomerRequest $request, int $id)
|
||||
{
|
||||
$customer = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
$mode = \App\Models\TenantInstallationSetting::firstOrCreate(
|
||||
['tenant_id' => $customer->tenant_id],
|
||||
['activation_mode' => 'list_order']
|
||||
)->activation_mode;
|
||||
abort_unless($mode === 'list_order', 422, 'Tenant mengatur persiapan aktivasi melalui Ticket.');
|
||||
|
||||
$customer = $this->service->update($customer, $request->validated(), $request->user());
|
||||
$customer = $this->service->ensureActivationCode($customer);
|
||||
return $this->updated(
|
||||
new CustomerResource($customer),
|
||||
'Persiapan aktivasi berhasil disimpan'
|
||||
);
|
||||
}
|
||||
|
||||
public function updateRegistration(UpdateCustomerRequest $request, int $id)
|
||||
{
|
||||
$customer = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
|
||||
return $this->updated(
|
||||
new CustomerResource($this->service->update($customer, $request->validated(), $request->user())),
|
||||
'Data registrasi berhasil disimpan'
|
||||
);
|
||||
}
|
||||
|
||||
public function activate(ActivateCustomerRequest $request, int $id)
|
||||
{
|
||||
$customer = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
|
||||
return $this->updated(new CustomerResource($this->service->activate($customer, $request->validated())), 'Customer berhasil diaktifkan');
|
||||
return $this->updated(new CustomerResource($this->service->activate($customer, $request->validated(), $request->user())), 'Customer berhasil diaktifkan');
|
||||
}
|
||||
|
||||
public function deactivate(Request $request, int $id)
|
||||
@@ -84,6 +169,16 @@ class CustomerController extends ApiController
|
||||
return $this->deleted('Customer dipindahkan ke Sampah');
|
||||
}
|
||||
|
||||
public function cancelOrder(Request $request, int $id)
|
||||
{
|
||||
$customer = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
abort_unless($customer->status === 'order', 422, 'Hanya customer dalam List Order yang dapat dihapus melalui aksi ini.');
|
||||
$this->service->trash($customer);
|
||||
|
||||
return $this->deleted('List Order dipindahkan ke Sampah');
|
||||
}
|
||||
|
||||
public function restore(Request $request, int $id)
|
||||
{
|
||||
$customer = $this->service->find($id, true);
|
||||
|
||||
@@ -22,7 +22,9 @@ class MenuController extends ApiController
|
||||
if ($user->isMasterAdmin() || $user->is_platform_staff) {
|
||||
$menus = MenuList::query()
|
||||
->where('is_active', true)
|
||||
->when($user->is_platform_staff && ! $user->isMasterAdmin(), fn ($q) => $q->whereIn('slug', ['role-applications', 'role-applications-tenants']))
|
||||
->when($user->is_platform_staff && ! $user->isMasterAdmin(), fn ($q) => $q->whereIn('slug', [
|
||||
'role-applications', 'role-applications-tenants', 'nas', 'nas-script-policies',
|
||||
]))
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
@@ -42,6 +44,7 @@ class MenuController extends ApiController
|
||||
'can_update' => true,
|
||||
'can_delete' => true,
|
||||
'can_approve' => true,
|
||||
'can_activate' => true,
|
||||
'can_export' => true,
|
||||
],
|
||||
];
|
||||
@@ -80,13 +83,14 @@ class MenuController extends ApiController
|
||||
DB::raw('MAX(CASE WHEN mgm.can_update THEN 1 ELSE 0 END) as can_update'),
|
||||
DB::raw('MAX(CASE WHEN mgm.can_delete THEN 1 ELSE 0 END) as can_delete'),
|
||||
DB::raw('MAX(CASE WHEN mgm.can_approve THEN 1 ELSE 0 END) as can_approve'),
|
||||
DB::raw('MAX(CASE WHEN mgm.can_activate THEN 1 ELSE 0 END) as can_activate'),
|
||||
DB::raw('MAX(CASE WHEN mgm.can_export THEN 1 ELSE 0 END) as can_export')
|
||||
)
|
||||
->orderBy('m.sort_order')
|
||||
->orderBy('m.id')
|
||||
->get();
|
||||
|
||||
$menus = $menuRows->map(function ($row) {
|
||||
$menus = $menuRows->map(function ($row) use ($tenantRole) {
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'parent_id' => $row->parent_id ? (int) $row->parent_id : null,
|
||||
@@ -102,6 +106,7 @@ class MenuController extends ApiController
|
||||
'can_update' => (bool) $row->can_update,
|
||||
'can_delete' => (bool) $row->can_delete,
|
||||
'can_approve' => (bool) $row->can_approve,
|
||||
'can_activate' => $tenantRole === 'tenant_owner' || (bool) $row->can_activate,
|
||||
'can_export' => (bool) $row->can_export,
|
||||
],
|
||||
];
|
||||
@@ -115,7 +120,7 @@ class MenuController extends ApiController
|
||||
$tree = $this->removeRestrictedMenus($tree, $restrictedSlugs);
|
||||
}
|
||||
if (! $user->isMasterAdmin() && ! $user->is_platform_staff) {
|
||||
$tree = $this->removeRestrictedMenus($tree, ['role-applications-tenants']);
|
||||
$tree = $this->removeRestrictedMenus($tree, ['role-applications-tenants', 'nas-script-policies', 'nas-radius-infrastructure']);
|
||||
}
|
||||
if (! $user->isTenantOwner($tenantId)) {
|
||||
$tree = $this->removeRestrictedMenus($tree, ['role-applications-staff']);
|
||||
|
||||
@@ -8,11 +8,15 @@ use App\Http\Requests\Nas\StoreNasResourceRequest;
|
||||
use App\Http\Requests\Nas\UpdateNasResourceRequest;
|
||||
use App\Http\Resources\Nas\NasResource;
|
||||
use App\Services\Nas\NasResourceService;
|
||||
use App\Services\Nas\MikrotikHealthService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NasResourceController extends ApiController
|
||||
{
|
||||
public function __construct(protected NasResourceService $service) {}
|
||||
public function __construct(
|
||||
protected NasResourceService $service,
|
||||
protected MikrotikHealthService $mikrotikHealth,
|
||||
) {}
|
||||
|
||||
public function index(IndexNasResourceRequest $request)
|
||||
{
|
||||
@@ -55,6 +59,18 @@ class NasResourceController extends ApiController
|
||||
return $this->deleted('Data NAS berhasil dihapus');
|
||||
}
|
||||
|
||||
public function testConnection(Request $request, int $id)
|
||||
{
|
||||
abort_unless($this->resource($request) === 'mikrotik', 404);
|
||||
$mikrotik = $this->service->find('mikrotik', $id);
|
||||
$this->authorizeTenant($request, $mikrotik->tenant_id);
|
||||
|
||||
return $this->success(
|
||||
$this->mikrotikHealth->check($mikrotik),
|
||||
'Koneksi RouterOS API berhasil'
|
||||
);
|
||||
}
|
||||
|
||||
private function resource(Request $request): string
|
||||
{
|
||||
return $request->route()->defaults['nas_resource'];
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Nas;
|
||||
use App\Http\Controllers\ApiController;
|
||||
use App\Models\NasPackageProfile;
|
||||
use App\Models\NasPackageProfileTarget;
|
||||
use App\Services\Nas\PackageProfileDeploymentService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use App\Models\NasScriptPolicy;
|
||||
|
||||
class PackageProfileDeploymentController extends ApiController
|
||||
{
|
||||
public function __construct(protected PackageProfileDeploymentService $service) {}
|
||||
public function index(Request $request, NasPackageProfile $profile) { $this->authorizeProfile($request,$profile); return $this->success($this->service->targets($profile),'Target deployment berhasil diambil'); }
|
||||
public function store(Request $request, NasPackageProfile $profile) { $this->authorizeProfile($request,$profile); $data=$request->validate(['nas_mikrotik_id'=>['required','integer','exists:nas_mikrotiks,id'],'external_profile_name'=>['required','string','max:255']]); return $this->created($this->service->saveTarget($profile,$data),'Target deployment berhasil disimpan'); }
|
||||
public function sync(Request $request, NasPackageProfileTarget $target) { $this->authorizeProfile($request,$target->packageProfile); $data=$request->validate(['mode'=>['required',Rule::in(['create','update','adopt'])]]); return $this->updated($this->service->sync($target,$data['mode']),'Profile berhasil disinkronkan'); }
|
||||
public function scriptOptions() { return $this->success(NasScriptPolicy::where('enabled',true)->with('latestVersion:id,nas_script_policy_id,version')->orderBy('service_type')->orderBy('event')->get(['id','code','name','service_type','event','current_version']),'Pilihan Script Policy berhasil diambil'); }
|
||||
private function authorizeProfile(Request $request, NasPackageProfile $profile): void { abort_unless($request->user()->isMasterAdmin() || (int)$profile->tenant_id === (int)$request->attributes->get('tenant_id'),403); }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Nas;
|
||||
|
||||
use App\Http\Controllers\ApiController;
|
||||
use App\Models\RadiusCluster;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class RadiusClusterController extends ApiController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorizeMaster($request);
|
||||
$data = $request->validate(['search'=>['nullable','string','max:255'],'status'=>['nullable',Rule::in(['planned','active','maintenance','inactive'])],'per_page'=>['nullable','integer','between:1,100']]);
|
||||
$rows = RadiusCluster::query()->when($data['search'] ?? null, fn ($q,$search) => $q->where(fn ($x) => $x->where('name','ILIKE',"%{$search}%")->orWhere('code','ILIKE',"%{$search}%")->orWhere('region','ILIKE',"%{$search}%")))->when($data['status'] ?? null, fn ($q,$status) => $q->where('status',$status))->orderBy('name')->paginate($data['per_page'] ?? 10);
|
||||
$rows->through(fn ($cluster) => $this->present($cluster));
|
||||
return $this->success($rows, 'Cluster RADIUS berhasil diambil');
|
||||
}
|
||||
|
||||
public function store(Request $request) { $this->authorizeMaster($request); return $this->created($this->present(RadiusCluster::create($this->validated($request))), 'Cluster RADIUS berhasil dibuat'); }
|
||||
public function show(Request $request, RadiusCluster $radiusCluster) { $this->authorizeMaster($request); return $this->success($this->present($radiusCluster), 'Detail cluster RADIUS berhasil diambil'); }
|
||||
public function update(Request $request, RadiusCluster $radiusCluster)
|
||||
{
|
||||
$this->authorizeMaster($request); $data=$this->validated($request,$radiusCluster);
|
||||
if (blank($data['provisioning_api_token'] ?? null)) unset($data['provisioning_api_token']);
|
||||
$radiusCluster->update($data); return $this->updated($this->present($radiusCluster->fresh()), 'Cluster RADIUS berhasil diperbarui');
|
||||
}
|
||||
public function destroy(Request $request, RadiusCluster $radiusCluster) { $this->authorizeMaster($request); $radiusCluster->delete(); return $this->deleted('Cluster RADIUS berhasil dihapus'); }
|
||||
|
||||
private function validated(Request $request, ?RadiusCluster $cluster=null): array
|
||||
{
|
||||
return $request->validate([
|
||||
'code'=>['required','alpha_dash','max:80',Rule::unique('radius_clusters','code')->ignore($cluster)], 'name'=>['required','string','max:255'], 'region'=>['nullable','string','max:80'],
|
||||
'auth_endpoints'=>['required','array','min:1','max:4'], 'auth_endpoints.*.host'=>['required','string','max:255'], 'auth_endpoints.*.port'=>['required','integer','between:1,65535'], 'auth_endpoints.*.priority'=>['required','integer','between:1,100'],
|
||||
'accounting_endpoints'=>['required','array','min:1','max:4'], 'accounting_endpoints.*.host'=>['required','string','max:255'], 'accounting_endpoints.*.port'=>['required','integer','between:1,65535'], 'accounting_endpoints.*.priority'=>['required','integer','between:1,100'],
|
||||
'coa_endpoints'=>['nullable','array','max:4'], 'coa_endpoints.*.host'=>['required','string','max:255'], 'coa_endpoints.*.port'=>['required','integer','between:1,65535'], 'coa_endpoints.*.priority'=>['required','integer','between:1,100'],
|
||||
'provisioning_api_url'=>['nullable','url:http,https','max:2000'], 'provisioning_api_token'=>['nullable','string','max:4000'], 'healthcheck_url'=>['nullable','url:http,https','max:2000'],
|
||||
'request_timeout'=>['required','integer','between:1,60'], 'retry_attempts'=>['required','integer','between:0,10'], 'status'=>['required',Rule::in(['planned','active','maintenance','inactive'])], 'notes'=>['nullable','string','max:5000'],
|
||||
]);
|
||||
}
|
||||
private function present(RadiusCluster $cluster): array { $data=$cluster->attributesToArray(); unset($data['provisioning_api_token']); return [...$data,'has_provisioning_api_token'=>filled($cluster->getRawOriginal('provisioning_api_token'))]; }
|
||||
private function authorizeMaster(Request $request): void { abort_unless($request->user()->isMasterAdmin(),403,'Infrastruktur RADIUS hanya dapat dikelola Master Admin.'); }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Nas;
|
||||
use App\Http\Controllers\ApiController;
|
||||
use App\Models\NasScriptPolicy;
|
||||
use App\Services\Nas\ScriptPolicyService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ScriptPolicyController extends ApiController
|
||||
{
|
||||
public function __construct(protected ScriptPolicyService $service) {}
|
||||
public function index(Request $request) { $this->authorizePlatform($request); $data=$request->validate(['search'=>['nullable','string','max:255'],'per_page'=>['nullable','integer','between:1,100']]); return $this->success($this->service->list($data),'Script Policy berhasil diambil'); }
|
||||
public function store(Request $request) { $this->authorizePlatform($request); return $this->created($this->service->save($this->validated($request),$request->user()->id),'Script Policy berhasil dibuat'); }
|
||||
public function update(Request $request, NasScriptPolicy $policy) { $this->authorizePlatform($request); return $this->updated($this->service->save($this->validated($request,$policy),$request->user()->id,$policy),'Versi Script Policy berhasil dibuat'); }
|
||||
public function show(Request $request, NasScriptPolicy $policy) { $this->authorizePlatform($request); return $this->success($policy->load(['latestVersion','versions'=>fn($q)=>$q->latest('version')]),'Detail Script Policy berhasil diambil'); }
|
||||
private function validated(Request $request, ?NasScriptPolicy $policy=null): array { return $request->validate(['code'=>['required','alpha_dash','max:100',Rule::unique('nas_script_policies','code')->ignore($policy)],'name'=>['required','string','max:255'],'service_type'=>['required',Rule::in(['ppp','hotspot'])],'event'=>['required',Rule::in(['on_up','on_down','on_login','on_logout'])],'script_template'=>['required','string','max:65535'],'change_notes'=>['nullable','string','max:1000'],'enabled'=>['nullable','boolean']]); }
|
||||
private function authorizePlatform(Request $request): void { abort_unless($request->user()->isMasterAdmin() || $request->user()->is_platform_staff,403,'Script Policy hanya dapat dikelola staff platform.'); }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Nas;
|
||||
|
||||
use App\Http\Controllers\ApiController;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantNasSetting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantNasSettingController extends ApiController
|
||||
{
|
||||
public function show(Request $request)
|
||||
{
|
||||
$tenant = $this->tenant($request);
|
||||
$setting = TenantNasSetting::firstOrCreate(['tenant_id' => $tenant->id], ['backend_mode' => 'routeros_api']);
|
||||
return $this->success($this->present($setting->load('radiusCluster:id,code,name,region,status')), 'Pengaturan backend NAS berhasil diambil');
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$tenant = $this->tenant($request);
|
||||
$setting = TenantNasSetting::firstOrCreate(['tenant_id' => $tenant->id], ['backend_mode' => 'routeros_api']);
|
||||
$data = $request->validate([
|
||||
'tenant_id' => ['nullable','integer','exists:tenants,id'],
|
||||
'backend_mode' => ['required', Rule::in(['routeros_api','platform_radius','external_radius'])],
|
||||
'radius_cluster_id' => ['nullable','integer','exists:radius_clusters,id'],
|
||||
'external_radius_host' => ['nullable','string','max:255'],
|
||||
'external_auth_port' => ['nullable','integer','between:1,65535'],
|
||||
'external_accounting_port' => ['nullable','integer','between:1,65535'],
|
||||
'external_coa_port' => ['nullable','integer','between:1,65535'],
|
||||
'external_radius_secret' => ['nullable','string','max:1000'],
|
||||
'external_provisioning_api_url' => ['nullable','url:http,https','max:2000'],
|
||||
'external_provisioning_api_token' => ['nullable','string','max:2000'],
|
||||
'accounting_enabled' => ['nullable','boolean'],
|
||||
'coa_enabled' => ['nullable','boolean'],
|
||||
]);
|
||||
$usesExternal = $data['backend_mode'] === 'external_radius';
|
||||
if ($usesExternal && empty($data['external_radius_host'])) throw ValidationException::withMessages(['external_radius_host'=>'Host RADIUS eksternal wajib diisi.']);
|
||||
$data['hybrid_radius_source'] = null;
|
||||
foreach (['external_radius_secret','external_provisioning_api_token'] as $secret) if (blank($data[$secret] ?? null)) unset($data[$secret]);
|
||||
unset($data['tenant_id']);
|
||||
$setting->update($data);
|
||||
return $this->updated($this->present($setting->fresh()->load('radiusCluster:id,code,name,region,status')), 'Pengaturan backend NAS berhasil disimpan');
|
||||
}
|
||||
|
||||
private function tenant(Request $request): Tenant
|
||||
{
|
||||
$id = $request->user()->isMasterAdmin() ? ($request->integer('tenant_id') ?: $request->attributes->get('tenant_id')) : $request->attributes->get('tenant_id');
|
||||
abort_unless($id, 422, 'Tenant wajib dipilih.');
|
||||
return Tenant::findOrFail($id);
|
||||
}
|
||||
|
||||
private function present(TenantNasSetting $setting): array
|
||||
{
|
||||
return [...$setting->attributesToArray(), 'has_external_radius_secret'=>filled($setting->getRawOriginal('external_radius_secret')), 'has_external_provisioning_api_token'=>filled($setting->getRawOriginal('external_provisioning_api_token')), 'radius_cluster'=>$setting->radiusCluster];
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use App\Http\Resources\Tenant\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Tenant\TenantService;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\TenantInstallationSetting;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TenantController extends ApiController
|
||||
{
|
||||
@@ -90,4 +92,44 @@ class TenantController extends ApiController
|
||||
403
|
||||
);
|
||||
}
|
||||
public function installationSetting(Request $request, Tenant $tenant)
|
||||
{
|
||||
$this->authorizeTenantAccess($request, $tenant);
|
||||
$setting = TenantInstallationSetting::firstOrCreate(['tenant_id' => $tenant->id], ['activation_mode' => 'list_order']);
|
||||
|
||||
return $this->success([
|
||||
...$setting->toArray(),
|
||||
'tenant' => new TenantResource($tenant),
|
||||
'ocr_api_key_configured' => filled($setting->ocr_api_key),
|
||||
'can_manage_ocr' => $request->user()->isMasterAdmin(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateInstallationSetting(Request $request, Tenant $tenant)
|
||||
{
|
||||
$this->authorizeTenantAccess($request, $tenant);
|
||||
$rules = ['activation_mode' => ['required', Rule::in(['list_order', 'ticket'])]];
|
||||
if ($request->user()->isMasterAdmin()) {
|
||||
$rules += [
|
||||
'ktp_ocr_enabled' => ['required', 'boolean'],
|
||||
'ocr_provider' => ['nullable', 'required_if:ktp_ocr_enabled,true', Rule::in(['gemini', 'openai'])],
|
||||
'ocr_api_key' => ['nullable', 'string', 'max:4096'],
|
||||
'clear_ocr_api_key' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
$data = $request->validate($rules);
|
||||
$setting = TenantInstallationSetting::firstOrCreate(['tenant_id' => $tenant->id], ['activation_mode' => 'list_order']);
|
||||
if ($request->user()->isMasterAdmin()) {
|
||||
if (! empty($data['clear_ocr_api_key'])) $data['ocr_api_key'] = null;
|
||||
elseif (blank($data['ocr_api_key'] ?? null)) unset($data['ocr_api_key']);
|
||||
unset($data['clear_ocr_api_key']);
|
||||
}
|
||||
$setting->update($data);
|
||||
|
||||
return $this->updated([
|
||||
...$setting->fresh()->toArray(),
|
||||
'ocr_api_key_configured' => filled($setting->fresh()->ocr_api_key),
|
||||
'can_manage_ocr' => $request->user()->isMasterAdmin(),
|
||||
], 'Setting tenant berhasil disimpan');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ use App\Http\Requests\Ticket\UpdateTicketRequest;
|
||||
use App\Http\Resources\Ticket\TicketResource;
|
||||
use App\Models\Ticket;
|
||||
use App\Services\Ticket\TicketService;
|
||||
use App\Models\Customer;
|
||||
use App\Models\TicketType;
|
||||
use App\Models\User;
|
||||
use App\Models\TicketMaterial;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TicketController extends ApiController
|
||||
{
|
||||
@@ -19,7 +24,13 @@ class TicketController extends ApiController
|
||||
public function index(IndexTicketRequest $request)
|
||||
{
|
||||
$filters = $request->validated();
|
||||
$tickets = Ticket::query()
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
$sortBy = $filters['sort_by'] ?? 'created_at';
|
||||
$sortDirection = $filters['sort_direction'] ?? 'desc';
|
||||
$tickets = Ticket::query()->with(['ticketType:id,code,name,need_approval,require_photo,require_material','customer:id,customer_code,name','tenant:id,tenant_name','creator:id,name','assignments.user:id,name'])
|
||||
->when(! $request->user()->isMasterAdmin(), fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
->when($request->user()->isMasterAdmin() && $tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
->when(! empty($filters['assigned_to_me']), fn ($query) => $query->whereHas('assignments', fn ($assignment) => $assignment->where('user_id', $request->user()->id)))
|
||||
->when(
|
||||
! empty($filters['search']),
|
||||
fn ($query) => $query->where(function ($searchQuery) use ($filters) {
|
||||
@@ -44,7 +55,7 @@ class TicketController extends ApiController
|
||||
! empty($filters['customer_id']),
|
||||
fn ($query) => $query->where('customer_id', $filters['customer_id'])
|
||||
)
|
||||
->latest()
|
||||
->orderBy($sortBy, $sortDirection)
|
||||
->paginate($filters['per_page'] ?? 10);
|
||||
|
||||
return $this->successPaginated(
|
||||
@@ -57,7 +68,7 @@ class TicketController extends ApiController
|
||||
public function store(StoreTicketRequest $request)
|
||||
{
|
||||
$ticket = $this->service->create(
|
||||
$request->validated()
|
||||
$request->validated(), $request->user(), $request->attributes->get('tenant_id')
|
||||
);
|
||||
|
||||
return $this->created(
|
||||
@@ -66,10 +77,11 @@ class TicketController extends ApiController
|
||||
);
|
||||
}
|
||||
|
||||
public function show(Ticket $ticket)
|
||||
public function show(Request $request, Ticket $ticket)
|
||||
{
|
||||
$this->authorizeTenant($request, $ticket);
|
||||
return $this->success(
|
||||
new TicketResource($ticket)
|
||||
new TicketResource($ticket->load(['ticketType','customer','tenant','creator','assignments.user','logs','materials','attachments']))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,6 +89,7 @@ class TicketController extends ApiController
|
||||
UpdateTicketRequest $request,
|
||||
Ticket $ticket
|
||||
) {
|
||||
$this->authorizeTenant($request, $ticket);
|
||||
$ticket = $this->service->update(
|
||||
$ticket,
|
||||
$request->validated()
|
||||
@@ -88,12 +101,72 @@ class TicketController extends ApiController
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Ticket $ticket)
|
||||
public function destroy(Request $request, Ticket $ticket)
|
||||
{
|
||||
$this->authorizeTenant($request, $ticket);
|
||||
$this->service->delete($ticket);
|
||||
|
||||
return $this->deleted(
|
||||
'Ticket berhasil dibatalkan'
|
||||
);
|
||||
}
|
||||
|
||||
public function options(Request $request)
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
return $this->success([
|
||||
'ticket_types' => TicketType::orderBy('name')->get(['id','code','name','need_approval','require_photo','require_material']),
|
||||
'customers' => Customer::when(! $request->user()->isMasterAdmin() || $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))->orderBy('name')->limit(500)->get(['id','customer_code','name','tenant_id']),
|
||||
'technicians' => User::whereHas('userTenants', fn ($q) => $q->where('tenant_id', $tenantId))->orderBy('name')->get(['id','name','email']),
|
||||
'installation_setting' => $tenantId
|
||||
? \App\Models\TenantInstallationSetting::firstOrCreate(['tenant_id' => $tenantId], ['activation_mode' => 'list_order'])
|
||||
: null,
|
||||
'package_profiles'=>\App\Models\NasPackageProfile::where('tenant_id',$tenantId)->where('status','active')->orderBy('name')->get(['id','name']),
|
||||
'nas_mikrotiks'=>\App\Models\NasMikrotik::where('tenant_id',$tenantId)->where('status','active')->orderBy('name')->get(['id','name']),
|
||||
'billing_profiles'=>\App\Models\BillingProfile::where('tenant_id',$tenantId)->where('status','active')->orderBy('name')->get(['id','name']),
|
||||
'topology_odps'=>\App\Models\TopologyNode::where('tenant_id',$tenantId)->whereHas('deviceType',fn($q)=>$q->whereRaw('UPPER(code) = ?',['ODP']))->orderBy('name')->get(['id','code','name']),
|
||||
], 'Pilihan ticket berhasil diambil');
|
||||
}
|
||||
|
||||
public function storeMaterial(Request $request, Ticket $ticket)
|
||||
{
|
||||
$this->authorizeTenant($request,$ticket);
|
||||
$data=$request->validate(['technician_material_id'=>['nullable','required_without:technician_consumable_id','integer','exists:technician_materials,id'],'technician_consumable_id'=>['nullable','required_without:technician_material_id','integer','exists:technician_consumables,id'],'qty'=>['required','numeric','gt:0'],'notes'=>['nullable','string','max:1000']]);
|
||||
abort_if(!empty($data['technician_material_id'])&&!empty($data['technician_consumable_id']),422,'Pilih satu material saja.');
|
||||
$material=\Illuminate\Support\Facades\DB::transaction(function()use($data,$ticket,$request){
|
||||
if(!empty($data['technician_material_id'])){$source=\App\Models\TechnicianMaterial::whereKey($data['technician_material_id'])->where('user_id',$request->user()->id)->where('status','assigned')->lockForUpdate()->firstOrFail();$qty=1;$source->update(['status'=>'used']);$name=$source->material_name;}
|
||||
else{$source=\App\Models\TechnicianConsumable::whereKey($data['technician_consumable_id'])->where('user_id',$request->user()->id)->where('status','assigned')->lockForUpdate()->firstOrFail();$qty=(float)$data['qty'];abort_if((float)$source->qty_remaining<$qty,422,'Jumlah material yang dibawa tidak mencukupi.');$source->update(['qty_used'=>(float)$source->qty_used+$qty,'qty_remaining'=>(float)$source->qty_remaining-$qty]);$name=$source->material_name;}
|
||||
\App\Models\MaterialMovement::create(['barcode_id'=>$source->barcode_id,'user_id'=>$request->user()->id,'from_user_id'=>$request->user()->id,'movement_type'=>'use','ticket_id'=>$ticket->id,'qty'=>$qty,'description'=>$data['notes']??null,'created_by'=>$request->user()->id,'created_at'=>now()]);
|
||||
$warehouseBarcode=\App\Models\ItemBarcode::where('barcode',$source->barcode_id)->first();
|
||||
if($warehouseBarcode)\App\Models\ItemMovement::create(['tenant_id'=>$ticket->tenant_id,'warehouse_item_id'=>$warehouseBarcode->warehouse_item_id,'item_barcode_id'=>$warehouseBarcode->id,'movement_type'=>'used_on_ticket','qty'=>$qty,'reference_type'=>'ticket','reference_id'=>$ticket->id,'from_user_id'=>$request->user()->id,'notes'=>$data['notes']??null,'created_by'=>$request->user()->id]);
|
||||
return $ticket->materials()->create([...$data,'material_name'=>$name,'qty'=>$qty,'used_by'=>$request->user()->id,'created_at'=>now()]);
|
||||
});
|
||||
\App\Models\TicketLog::create(['ticket_id'=>$ticket->id,'user_id'=>$request->user()->id,'activity'=>'Material Digunakan','notes'=>$material->material_name.' x '.$material->qty,'created_at'=>now()]);
|
||||
return $this->created($material->load('user:id,name'),'Material ticket berhasil ditambahkan');
|
||||
}
|
||||
|
||||
public function materialOptions(Request $request, Ticket $ticket)
|
||||
{
|
||||
$this->authorizeTenant($request,$ticket);$userId=$request->user()->id;
|
||||
return $this->success(['serialized'=>\App\Models\TechnicianMaterial::where('user_id',$userId)->where('status','assigned')->get(['id','barcode_id','material_name','serial_number']),'consumables'=>\App\Models\TechnicianConsumable::where('user_id',$userId)->where('status','assigned')->where('qty_remaining','>',0)->get(['id','barcode_id','material_name','unit','qty_remaining'])],'Material yang dibawa berhasil diambil');
|
||||
}
|
||||
public function installationPorts(Request $request,Ticket $ticket,int $node,\App\Services\Customer\CustomerService $customers){$this->authorizeTenant($request,$ticket);return $this->success($customers->availableOdpPorts($node,$request->user(),$ticket->tenant_id,$ticket->customer_id),'Port ODP berhasil diambil');}
|
||||
|
||||
public function destroyMaterial(Request $request, Ticket $ticket, TicketMaterial $material)
|
||||
{
|
||||
$this->authorizeTenant($request,$ticket);abort_unless((int)$material->ticket_id===(int)$ticket->id,404);
|
||||
abort_if(in_array($ticket->status,['resolved','closed'],true),422,'Material ticket yang sudah selesai tidak dapat diubah.');
|
||||
\Illuminate\Support\Facades\DB::transaction(function()use($request,$ticket,$material){
|
||||
if($material->technician_material_id){$source=\App\Models\TechnicianMaterial::lockForUpdate()->find($material->technician_material_id);if($source)$source->update(['status'=>'assigned']);}
|
||||
else{$source=\App\Models\TechnicianConsumable::lockForUpdate()->find($material->technician_consumable_id);if($source)$source->update(['qty_used'=>max(0,(float)$source->qty_used-(float)$material->qty),'qty_remaining'=>(float)$source->qty_remaining+(float)$material->qty,'status'=>'assigned']);}
|
||||
if(isset($source)&&$source){\App\Models\MaterialMovement::create(['barcode_id'=>$source->barcode_id,'user_id'=>$request->user()->id,'to_user_id'=>$request->user()->id,'movement_type'=>'return','ticket_id'=>$ticket->id,'qty'=>$material->qty,'description'=>'Pembatalan pemakaian material pada ticket','created_by'=>$request->user()->id,'created_at'=>now()]);$warehouseBarcode=\App\Models\ItemBarcode::where('barcode',$source->barcode_id)->first();if($warehouseBarcode)\App\Models\ItemMovement::create(['tenant_id'=>$ticket->tenant_id,'warehouse_item_id'=>$warehouseBarcode->warehouse_item_id,'item_barcode_id'=>$warehouseBarcode->id,'movement_type'=>'ticket_usage_cancelled','qty'=>$material->qty,'reference_type'=>'ticket','reference_id'=>$ticket->id,'to_user_id'=>$request->user()->id,'notes'=>'Material dikembalikan ke saldo teknisi','created_by'=>$request->user()->id]);}
|
||||
$material->delete();
|
||||
});
|
||||
return $this->deleted('Material ticket berhasil dihapus dan dikembalikan ke daftar bawaan user');
|
||||
}
|
||||
|
||||
private function authorizeTenant(Request $request, Ticket $ticket): void
|
||||
{
|
||||
abort_unless($request->user()->isMasterAdmin() || (int) $ticket->tenant_id === (int) $request->attributes->get('tenant_id'), 403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ use App\Http\Requests\Ticket\CloseTicketRequest;
|
||||
use App\Http\Requests\Ticket\CancelTicketRequest;
|
||||
use App\Http\Requests\Ticket\EscalateTicketRequest;
|
||||
use App\Http\Requests\Ticket\StartTicketRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TicketWorkflowController extends ApiController
|
||||
{
|
||||
@@ -29,6 +31,7 @@ class TicketWorkflowController extends ApiController
|
||||
*/
|
||||
public function history(Ticket $ticket)
|
||||
{
|
||||
$this->authorizeTicket($ticket);
|
||||
$data = $this->service->history($ticket);
|
||||
|
||||
return $this->success(
|
||||
@@ -44,6 +47,7 @@ class TicketWorkflowController extends ApiController
|
||||
AssignTicketRequest $request,
|
||||
Ticket $ticket
|
||||
) {
|
||||
$this->authorizeTicket($ticket);
|
||||
$ticket = $this->service->assign(
|
||||
$ticket,
|
||||
$request->technicians,
|
||||
@@ -64,6 +68,7 @@ class TicketWorkflowController extends ApiController
|
||||
ReassignTicketRequest $request,
|
||||
Ticket $ticket
|
||||
) {
|
||||
$this->authorizeTicket($ticket);
|
||||
$ticket = $this->service->reassign(
|
||||
$ticket,
|
||||
$request->technicians,
|
||||
@@ -84,6 +89,7 @@ class TicketWorkflowController extends ApiController
|
||||
ApproveTicketRequest $request,
|
||||
Ticket $ticket
|
||||
) {
|
||||
$this->authorizeTicket($ticket);
|
||||
$ticket = $this->service->approve(
|
||||
$ticket,
|
||||
$request->notes,
|
||||
@@ -103,6 +109,7 @@ class TicketWorkflowController extends ApiController
|
||||
RejectTicketRequest $request,
|
||||
Ticket $ticket
|
||||
) {
|
||||
$this->authorizeTicket($ticket);
|
||||
$ticket = $this->service->reject(
|
||||
$ticket,
|
||||
$request->reason,
|
||||
@@ -122,6 +129,7 @@ class TicketWorkflowController extends ApiController
|
||||
StartTicketRequest $request,
|
||||
Ticket $ticket
|
||||
) {
|
||||
$this->authorizeTicket($ticket);
|
||||
$ticket = $this->service->start(
|
||||
$ticket,
|
||||
$request->validated(),
|
||||
@@ -141,10 +149,12 @@ class TicketWorkflowController extends ApiController
|
||||
PendingTicketRequest $request,
|
||||
Ticket $ticket
|
||||
) {
|
||||
$this->authorizeTicket($ticket);
|
||||
$ticket = $this->service->pending(
|
||||
$ticket,
|
||||
$request->reason,
|
||||
auth()->id() ?? 1
|
||||
auth()->id() ?? 1,
|
||||
$request->validated('stored_file_ids')
|
||||
);
|
||||
|
||||
return $this->success(
|
||||
@@ -158,6 +168,7 @@ class TicketWorkflowController extends ApiController
|
||||
*/
|
||||
public function resume(Ticket $ticket)
|
||||
{
|
||||
$this->authorizeTicket($ticket);
|
||||
$ticket = $this->service->resume(
|
||||
$ticket,
|
||||
auth()->id() ?? 1
|
||||
@@ -176,10 +187,12 @@ class TicketWorkflowController extends ApiController
|
||||
ResolveTicketRequest $request,
|
||||
Ticket $ticket
|
||||
) {
|
||||
$this->authorizeTicket($ticket);
|
||||
$ticket = $this->service->resolve(
|
||||
$ticket,
|
||||
$request->notes,
|
||||
auth()->id() ?? 1
|
||||
auth()->id() ?? 1,
|
||||
$request->validated()
|
||||
);
|
||||
|
||||
return $this->success(
|
||||
@@ -195,6 +208,7 @@ class TicketWorkflowController extends ApiController
|
||||
CloseTicketRequest $request,
|
||||
Ticket $ticket
|
||||
) {
|
||||
$this->authorizeTicket($ticket);
|
||||
$ticket = $this->service->close(
|
||||
$ticket,
|
||||
$request->notes,
|
||||
@@ -207,6 +221,42 @@ class TicketWorkflowController extends ApiController
|
||||
);
|
||||
}
|
||||
|
||||
public function verify(Request $request, Ticket $ticket)
|
||||
{
|
||||
$this->authorizeTicket($ticket);
|
||||
abort_unless($ticket->status === 'resolved', 422, 'Hanya ticket selesai yang dapat diverifikasi.');
|
||||
$data=$request->validate(['notes'=>['required','string','max:2000']]);
|
||||
$ticket->update(['verified_by'=>$request->user()->id,'verified_at'=>now(),'verification_notes'=>$data['notes']]);
|
||||
\App\Models\TicketLog::create(['ticket_id'=>$ticket->id,'user_id'=>$request->user()->id,'activity'=>'Verified','notes'=>$data['notes'],'created_at'=>now()]);
|
||||
return $this->updated(new TicketResource($ticket->fresh()),'Ticket berhasil diverifikasi');
|
||||
}
|
||||
|
||||
public function installationStep(Request $request, Ticket $ticket, \App\Services\Customer\CustomerService $customers)
|
||||
{
|
||||
$this->authorizeTicket($ticket);
|
||||
abort_unless($ticket->source === 'new_installation' && $ticket->customer_id,422,'Ticket bukan pemasangan baru.');
|
||||
abort_unless(in_array($ticket->status,['progress','pending'],true),422,'Pekerjaan harus dimulai terlebih dahulu.');
|
||||
$step=$request->validate(['step'=>['required',Rule::in(['documentation','service','activation'])]])['step'];
|
||||
$expected=$ticket->installation_step ?: 'documentation'; abort_unless($step===$expected,422,"Tahap yang harus dikerjakan adalah {$expected}.");
|
||||
$customer=$customers->find($ticket->customer_id);
|
||||
if($step==='documentation'){
|
||||
$data=$request->validate(['stored_file_ids'=>['required','array','min:1'],'stored_file_ids.*'=>['integer','exists:stored_files,id'],'topology_id'=>['required','integer','exists:topology_nodes,id'],'topology_port_number'=>['required','integer','min:1']]);
|
||||
$images=collect($data['stored_file_ids'])->map(fn($id)=>['stored_file_id'=>$id,'category'=>'installation','is_cover'=>false])->all();
|
||||
$customers->update($customer,['tenant_id'=>$customer->tenant_id,'topology_id'=>$data['topology_id'],'topology_port_number'=>$data['topology_port_number'],'images'=>$images],$request->user());
|
||||
foreach($data['stored_file_ids'] as $id)\App\Models\TicketAttachment::firstOrCreate(['ticket_id'=>$ticket->id,'stored_file_id'=>$id,'stage'=>'installation_documentation'],['uploaded_by'=>$request->user()->id]);
|
||||
$next='service';
|
||||
}elseif($step==='service'){
|
||||
$data=$request->validate(['package_profile_id'=>['required','integer','exists:nas_package_profiles,id'],'nas_mikrotik_id'=>['required','integer','exists:nas_mikrotiks,id'],'billing_profile_id'=>['required','integer','exists:billing_profiles,id']]);
|
||||
$customers->update($customer,['tenant_id'=>$customer->tenant_id,...$data],$request->user()); $next='activation';
|
||||
}else{
|
||||
$data=$request->validate(['package_profile_id'=>['required','integer'],'nas_mikrotik_id'=>['required','integer'],'billing_profile_id'=>['required','integer'],'external_username'=>['required','string','max:255'],'external_password'=>['required','string','min:4'],'serial_number'=>['nullable','string','max:120'],'mac_address'=>['nullable','regex:/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/']]);
|
||||
$customers->activate($customer,['activation_source'=>'ticket',...$data],$request->user()); $next='completed';
|
||||
}
|
||||
$ticket->update(['installation_step'=>$next]);
|
||||
\App\Models\TicketLog::create(['ticket_id'=>$ticket->id,'user_id'=>$request->user()->id,'activity'=>'Tahap Pemasangan Selesai','notes'=>$step.' → '.$next,'created_at'=>now()]);
|
||||
return $this->updated(new TicketResource($ticket->fresh()->load(['customer','attachments'])),'Tahap pemasangan berhasil disimpan');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel
|
||||
*/
|
||||
@@ -214,6 +264,7 @@ class TicketWorkflowController extends ApiController
|
||||
CancelTicketRequest $request,
|
||||
Ticket $ticket
|
||||
) {
|
||||
$this->authorizeTicket($ticket);
|
||||
$ticket = $this->service->cancel(
|
||||
$ticket,
|
||||
$request->reason,
|
||||
@@ -233,6 +284,7 @@ class TicketWorkflowController extends ApiController
|
||||
EscalateTicketRequest $request,
|
||||
Ticket $ticket
|
||||
) {
|
||||
$this->authorizeTicket($ticket);
|
||||
$ticket = $this->service->escalate(
|
||||
$ticket,
|
||||
$request->reason,
|
||||
@@ -244,4 +296,11 @@ class TicketWorkflowController extends ApiController
|
||||
'Ticket berhasil dieskalasi'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizeTicket(Ticket $ticket): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$tenantId = request()->attributes->get('tenant_id');
|
||||
abort_unless($user?->isMasterAdmin() || (int) $ticket->tenant_id === (int) $tenantId, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Warehouse;
|
||||
use App\Http\Controllers\ApiController;use App\Models\{ItemBarcode,ItemCode,ItemMovement,ItemStockLog,WarehouseItem};use Illuminate\Http\Request;use Illuminate\Support\Facades\DB;use Illuminate\Support\Str;use Illuminate\Validation\Rule;
|
||||
class WarehouseBatchController extends ApiController
|
||||
{
|
||||
public function index(Request $r){$t=$r->attributes->get('tenant_id');$status=$r->input('status','active');$q=WarehouseItem::with(['warehouse:id,code,name','itemCode','barcodes'])->when(!$r->user()->isMasterAdmin()||$t,fn($x)=>$x->where('tenant_id',$t))->where('status',$status)->when($r->search,fn($x,$s)=>$x->where(fn($y)=>$y->where('item_number','ilike',"%$s%")->orWhereHas('itemCode',fn($z)=>$z->where('name','ilike',"%$s%")->orWhere('code','ilike',"%$s%"))))->orderByDesc('id')->paginate(min((int)$r->input('per_page',10),100));return $this->successPaginated($q,\Illuminate\Http\Resources\Json\JsonResource::class,'Daftar barang berhasil diambil');}
|
||||
public function store(Request $r)
|
||||
{
|
||||
$t=$r->user()->isMasterAdmin()?($r->input('tenant_id')?:$r->attributes->get('tenant_id')):$r->attributes->get('tenant_id');
|
||||
$d=$r->validate(['tenant_id'=>['nullable','integer','exists:tenants,id'],'warehouse_id'=>['required','integer','exists:warehouses,id'],'item_code_id'=>['required','integer','exists:item_codes,id'],'name'=>['required','string','max:255'],'quantity'=>['required','numeric','gt:0'],'package_count'=>['nullable','integer','min:1'],'received_at'=>['required','date'],'notes'=>['nullable','string']]);
|
||||
$code=ItemCode::whereKey($d['item_code_id'])->where('tenant_id',$t)->firstOrFail();
|
||||
abort_unless(\App\Models\Warehouse::whereKey($d['warehouse_id'])->where('tenant_id',$t)->exists(),422,'Gudang bukan milik tenant aktif.');
|
||||
return DB::transaction(function()use($r,$d,$t,$code){
|
||||
$base=$code->code.'-'.now()->format('dmy');
|
||||
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))',[$t.'|'.$base]);
|
||||
$seq=WarehouseItem::where('tenant_id',$t)->where('item_number','like',$base.'%')->count()+1;
|
||||
do{$number=$base.'-'.str_pad((string)$seq++,3,'0',STR_PAD_LEFT);}while(WarehouseItem::where('tenant_id',$t)->where('item_number',$number)->exists());
|
||||
$item=WarehouseItem::create(['tenant_id'=>$t,'warehouse_id'=>$d['warehouse_id'],'item_code_id'=>$code->id,'item_number'=>$number,'status'=>'draft','sku'=>$code->code,'name'=>$d['name'],'tracking_type'=>$code->tracking_type==='serialized'?'serialized':'bulk','unit'=>$code->unit,'minimum_stock'=>$code->minimum_stock,'received_qty'=>$d['quantity'],'received_at'=>$d['received_at'],'notes'=>$d['notes']??null,'is_active'=>false]);
|
||||
$count=$code->tracking_type==='serialized'?(int)$d['quantity']:max(1,(int)($d['package_count']??1));
|
||||
abort_if($code->tracking_type==='serialized'&&(float)$d['quantity']!==floor((float)$d['quantity']),422,'Barang serialized harus berjumlah bilangan bulat.');
|
||||
$remaining=(float)$d['quantity'];
|
||||
for($i=1;$i<=$count;$i++){$qty=$code->tracking_type==='serialized'?1:($i===$count?$remaining:round((float)$d['quantity']/$count,2));$remaining-=$qty;ItemBarcode::create(['tenant_id'=>$t,'warehouse_item_id'=>$item->id,'package_number'=>$i,'barcode'=>$this->barcode($code->code,$t),'tracking_type'=>$code->tracking_type,'unit'=>$code->unit,'initial_qty'=>$qty,'remaining_qty'=>$qty,'status'=>'draft']);}
|
||||
return $this->created($item->load(['warehouse','itemCode','barcodes']),'Barang draft dan barcode berhasil dibuat');
|
||||
});
|
||||
}
|
||||
public function show(Request $r,WarehouseItem $item){$this->auth($r,$item);return $this->success($item->load(['warehouse','itemCode','barcodes','movements']),'Detail barang berhasil diambil');}
|
||||
public function updateSerials(Request $r,WarehouseItem $item){$this->auth($r,$item);abort_unless($item->status==='draft',422,'Serial number hanya dapat diubah pada draft.');$d=$r->validate(['barcodes'=>['required','array','min:1'],'barcodes.*.id'=>['required','integer'],'barcodes.*.serial_number'=>['nullable','string','max:120']]);DB::transaction(function()use($d,$item){foreach($d['barcodes'] as $row){$barcode=$item->barcodes()->findOrFail($row['id']);$serial=$row['serial_number']?:null;if($serial&&ItemBarcode::where('tenant_id',$item->tenant_id)->where('serial_number',$serial)->whereKeyNot($barcode->id)->exists())abort(422,"Serial number {$serial} sudah digunakan.");$barcode->update(['serial_number'=>$serial]);}});return $this->updated($item->fresh()->load('barcodes'),'Serial number berhasil diperbarui');}
|
||||
public function post(Request $r,WarehouseItem $item){$this->auth($r,$item);abort_unless($item->status==='draft',422,'Hanya barang draft yang dapat diposting.');$item->load(['itemCode','barcodes']);abort_if($item->barcodes->isEmpty(),422,'Barcode belum tersedia.');if($item->itemCode->need_serial_number)abort_if($item->barcodes->contains(fn($b)=>blank($b->serial_number)),422,'Semua serial number wajib diisi sebelum posting.');return DB::transaction(function()use($r,$item){$item->barcodes()->update(['status'=>'available']);$item->update(['status'=>'active','is_active'=>true,'posted_by'=>$r->user()->id,'posted_at'=>now()]);$move=ItemMovement::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'movement_type'=>'stock_posted','qty'=>$item->received_qty,'notes'=>'Draft diposting menjadi stok aktif','created_by'=>$r->user()->id]);ItemStockLog::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_movement_id'=>$move->id,'qty_before'=>0,'qty_change'=>$item->received_qty,'qty_after'=>$item->received_qty]);return $this->updated($item->fresh()->load(['warehouse','itemCode','barcodes']),'Barang berhasil diposting ke Daftar Barang');});}
|
||||
public function destroy(Request $r,WarehouseItem $item){$this->auth($r,$item);abort_unless($item->status==='draft',422,'Hanya barang draft yang dapat dihapus.');$item->delete();return $this->deleted('Draft barang dan barcode berhasil dihapus');}
|
||||
public function barcodeMovements(Request $r,WarehouseItem $item,ItemBarcode $barcode){$this->auth($r,$item);abort_unless((int)$barcode->warehouse_item_id===(int)$item->id,404);return $this->success(ItemMovement::where('item_barcode_id',$barcode->id)->latest()->get(),'Histori barcode berhasil diambil');}
|
||||
private function barcode(string $code,int $tenant):string{do{$random='';$chars='ABCDEFGHJKLMNPQRSTUVWXYZ23456789';for($i=0;$i<6;$i++)$random.=$chars[random_int(0,strlen($chars)-1)];$value=$code.'-'.now()->format('dmy').'-'.$random;}while(ItemBarcode::where('barcode',$value)->exists());return $value;}
|
||||
private function auth(Request $r,WarehouseItem $i){abort_unless($r->user()->isMasterAdmin()||(int)$i->tenant_id===(int)$r->attributes->get('tenant_id'),403);}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Warehouse;
|
||||
use App\Http\Controllers\ApiController;use App\Models\{WarehouseItem,ItemBarcode,ItemCode,ItemMovement,ItemStockLog};use Illuminate\Http\Request;use Illuminate\Support\Facades\DB;use Illuminate\Validation\Rule;
|
||||
class WarehouseController extends ApiController
|
||||
{
|
||||
public function index(Request $r){$t=$r->attributes->get('tenant_id');$q=WarehouseItem::with(['itemCode','barcodes'])->when(!$r->user()->isMasterAdmin()||$t,fn($x)=>$x->where('tenant_id',$t))->when($r->search,fn($x,$s)=>$x->whereHas('itemCode',fn($y)=>$y->where('name','ilike',"%$s%")->orWhere('code','ilike',"%$s%")))->orderBy($r->input('sort_by','id'),$r->input('sort_direction','desc'))->paginate(min((int)$r->input('per_page',10),100));return $this->successPaginated($q,\Illuminate\Http\Resources\Json\JsonResource::class,'Daftar barang berhasil diambil');}
|
||||
public function store(Request $r){$d=$this->validateItem($r);$d['tenant_id']=$r->user()->isMasterAdmin()?($d['tenant_id']??$r->attributes->get('tenant_id')):$r->attributes->get('tenant_id');return $this->created(WarehouseItem::create($d),'Barang berhasil dibuat');}
|
||||
public function update(Request $r,WarehouseItem $item){$this->auth($r,$item);$item->update($this->validateItem($r,$item));return $this->updated($item->fresh(),'Barang berhasil diperbarui');}
|
||||
public function destroy(Request $r,WarehouseItem $item){$this->auth($r,$item);abort_if($item->barcodes()->where('remaining_qty','>',0)->exists(),422,'Barang yang masih memiliki stok tidak dapat dihapus.');$item->delete();return $this->deleted('Barang berhasil dihapus');}
|
||||
public function receive(Request $r,WarehouseItem $item){$this->auth($r,$item);$item->loadMissing('itemCode');$type=$item->itemCode?->tracking_type??($item->tracking_type==='serialized'?'serialized':'quantity');$needSerial=(bool)($item->itemCode?->need_serial_number);$d=$r->validate(['barcode'=>['required','string','max:120','unique:item_barcodes,barcode'],'serial_number'=>[$needSerial?'required':'nullable','string','max:120'],'qty'=>['required','numeric','gt:0'],'notes'=>['nullable','string']]);if($type==='serialized')$d['qty']=1;return DB::transaction(function()use($r,$item,$d,$type){$before=(float)$item->barcodes()->sum('remaining_qty');$barcode=ItemBarcode::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'barcode'=>$d['barcode'],'tracking_type'=>$type,'unit'=>$item->itemCode?->unit??$item->unit,'serial_number'=>$d['serial_number']??null,'initial_qty'=>$d['qty'],'remaining_qty'=>$d['qty'],'status'=>'available']);$move=ItemMovement::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_barcode_id'=>$barcode->id,'movement_type'=>'stock_in','qty'=>$d['qty'],'notes'=>$d['notes']??null,'created_by'=>$r->user()->id]);ItemStockLog::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_barcode_id'=>$barcode->id,'item_movement_id'=>$move->id,'qty_before'=>$before,'qty_change'=>$d['qty'],'qty_after'=>$before+$d['qty'],'barcode_qty_before'=>0,'barcode_qty_change'=>$d['qty'],'barcode_qty_after'=>$d['qty']]);return $this->created($barcode,'Stok masuk berhasil dicatat');});}
|
||||
public function options(Request $r){$t=$r->attributes->get('tenant_id');$users=\App\Models\UserTenant::with('user:id,name')->where('tenant_id',$t)->get()->pluck('user')->filter()->unique('id')->values();$codes=ItemCode::where('tenant_id',$t)->where('is_active',true)->orderBy('name')->get();return $this->success(['users'=>$users,'item_codes'=>$codes],'Pilihan gudang berhasil diambil');}
|
||||
public function issue(Request $r,ItemBarcode $barcode){$item=$barcode->item;$this->auth($r,$item);$d=$r->validate(['user_id'=>['required','integer','exists:users,id'],'qty'=>['required','numeric','gt:0'],'notes'=>['nullable','string','max:1000']]);abort_unless(\App\Models\UserTenant::where('tenant_id',$item->tenant_id)->where('user_id',$d['user_id'])->exists(),422,'Penerima bukan user tenant ini.');return DB::transaction(function()use($r,$item,$barcode,$d){$locked=ItemBarcode::whereKey($barcode->id)->lockForUpdate()->firstOrFail();$barcodeBefore=(float)$locked->remaining_qty;$serialized=($locked->tracking_type??$item->tracking_type)==='serialized';$qty=$serialized?1:(float)$d['qty'];abort_if($barcodeBefore<$qty,422,'Stok barcode tidak mencukupi.');$before=(float)$item->barcodes()->sum('remaining_qty');if($serialized){abort_if(\App\Models\TechnicianMaterial::where('barcode_id',$locked->barcode)->exists(),422,'Barcode sudah pernah dikeluarkan.');\App\Models\TechnicianMaterial::create(['user_id'=>$d['user_id'],'barcode_id'=>$locked->barcode,'inventory_item_id'=>$item->id,'material_name'=>$item->name,'serial_number'=>$locked->serial_number,'status'=>'assigned','assigned_at'=>now(),'assigned_by'=>$r->user()->id]);}else{abort_if(\App\Models\TechnicianConsumable::where('barcode_id',$locked->barcode)->exists(),422,'Satu barcode quantity hanya dapat diserahkan kepada satu user sampai dikembalikan.');\App\Models\TechnicianConsumable::create(['user_id'=>$d['user_id'],'barcode_id'=>$locked->barcode,'inventory_item_id'=>$item->id,'material_name'=>$item->name,'unit'=>$locked->unit??$item->unit,'qty_received'=>$qty,'qty_used'=>0,'qty_remaining'=>$qty,'status'=>'assigned','assigned_at'=>now(),'assigned_by'=>$r->user()->id]);}$barcodeAfter=$barcodeBefore-$qty;$locked->update(['remaining_qty'=>$barcodeAfter,'status'=>$barcodeAfter>0?'available':'issued']);$move=ItemMovement::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_barcode_id'=>$locked->id,'movement_type'=>'issue_to_technician','qty'=>$qty,'to_user_id'=>$d['user_id'],'notes'=>$d['notes']??null,'created_by'=>$r->user()->id]);ItemStockLog::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_barcode_id'=>$locked->id,'item_movement_id'=>$move->id,'qty_before'=>$before,'qty_change'=>-$qty,'qty_after'=>$before-$qty,'barcode_qty_before'=>$barcodeBefore,'barcode_qty_change'=>-$qty,'barcode_qty_after'=>$barcodeAfter]);return $this->created($move,'Barang berhasil dikeluarkan kepada user');});}
|
||||
public function movements(Request $r){$t=$r->attributes->get('tenant_id');return $this->successPaginated(ItemMovement::when(!$r->user()->isMasterAdmin()||$t,fn($q)=>$q->where('tenant_id',$t))->latest()->paginate(min((int)$r->input('per_page',20),100)),\Illuminate\Http\Resources\Json\JsonResource::class,'Pergerakan barang berhasil diambil');}
|
||||
public function technicianInventory(Request $r){$t=$r->attributes->get('tenant_id');$userIds=\App\Models\UserTenant::where('tenant_id',$t)->pluck('user_id');$serialized=\App\Models\TechnicianMaterial::with('user:id,name')->whereIn('user_id',$userIds)->where('status','assigned')->get()->map(fn($x)=>['id'=>'s-'.$x->id,'user'=>$x->user?->name,'material_name'=>$x->material_name,'barcode'=>$x->barcode_id,'tracking_type'=>'serialized','qty'=>1,'unit'=>'pcs','status'=>$x->status,'assigned_at'=>$x->assigned_at]);$quantity=\App\Models\TechnicianConsumable::with('user:id,name')->whereIn('user_id',$userIds)->where('status','assigned')->where('qty_remaining','>',0)->get()->map(fn($x)=>['id'=>'q-'.$x->id,'user'=>$x->user?->name,'material_name'=>$x->material_name,'barcode'=>$x->barcode_id,'tracking_type'=>'quantity','qty'=>$x->qty_remaining,'unit'=>$x->unit,'status'=>$x->status,'assigned_at'=>$x->assigned_at]);$all=$serialized->concat($quantity)->sortByDesc('assigned_at')->values();$page=max(1,(int)$r->input('page',1));$per=min(max(1,(int)$r->input('per_page',10)),100);$p=new \Illuminate\Pagination\LengthAwarePaginator($all->forPage($page,$per)->values(),$all->count(),$per,$page,['path'=>$r->url(),'query'=>$r->query()]);return $this->successPaginated($p,\Illuminate\Http\Resources\Json\JsonResource::class,'Peralatan teknisi berhasil diambil');}
|
||||
public function returnFromTechnician(Request $r){$d=$r->validate(['source_type'=>['required',Rule::in(['serialized','quantity'])],'source_id'=>['required','integer'],'qty'=>['required','numeric','gt:0'],'notes'=>['nullable','string','max:1000']]);$t=$r->attributes->get('tenant_id');return DB::transaction(function()use($r,$d,$t){$source=$d['source_type']==='serialized'?\App\Models\TechnicianMaterial::lockForUpdate()->findOrFail($d['source_id']):\App\Models\TechnicianConsumable::lockForUpdate()->findOrFail($d['source_id']);abort_unless(\App\Models\UserTenant::where('tenant_id',$t)->where('user_id',$source->user_id)->exists(),403);$barcode=ItemBarcode::where('barcode',$source->barcode_id)->lockForUpdate()->firstOrFail();$item=$barcode->item;$qty=$d['source_type']==='serialized'?1:(float)$d['qty'];if($d['source_type']==='serialized'){abort_unless($source->status==='assigned',422,'Barang sudah tidak berada pada teknisi.');$source->update(['status'=>'returned']);}else{abort_if((float)$source->qty_remaining<$qty,422,'Jumlah pengembalian melebihi sisa teknisi.');$remaining=(float)$source->qty_remaining-$qty;$source->update(['qty_remaining'=>$remaining,'status'=>$remaining>0?'assigned':'returned']);}$itemBefore=(float)$item->barcodes()->sum('remaining_qty');$barcodeBefore=(float)$barcode->remaining_qty;$barcodeAfter=$barcodeBefore+$qty;$barcode->update(['remaining_qty'=>$barcodeAfter,'status'=>'available']);$move=ItemMovement::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_barcode_id'=>$barcode->id,'movement_type'=>'return_to_warehouse','qty'=>$qty,'from_user_id'=>$source->user_id,'notes'=>$d['notes']??null,'created_by'=>$r->user()->id]);ItemStockLog::create(['tenant_id'=>$item->tenant_id,'warehouse_item_id'=>$item->id,'item_barcode_id'=>$barcode->id,'item_movement_id'=>$move->id,'qty_before'=>$itemBefore,'qty_change'=>$qty,'qty_after'=>$itemBefore+$qty,'barcode_qty_before'=>$barcodeBefore,'barcode_qty_change'=>$qty,'barcode_qty_after'=>$barcodeAfter]);return $this->created($move,'Barang berhasil dikembalikan ke gudang');});}
|
||||
public function codeIndex(Request $r){$t=$r->attributes->get('tenant_id');$q=ItemCode::when(!$r->user()->isMasterAdmin()||$t,fn($x)=>$x->where('tenant_id',$t))->when($r->search,fn($x,$s)=>$x->where(fn($y)=>$y->where('name','ilike',"%$s%")->orWhere('code','ilike',"%$s%")))->orderBy($r->input('sort_by','name'),$r->input('sort_direction','asc'))->paginate(min((int)$r->input('per_page',10),100));return $this->successPaginated($q,\Illuminate\Http\Resources\Json\JsonResource::class,'Kode barang berhasil diambil');}
|
||||
public function codeStore(Request $r){$d=$this->validateCode($r);$d['tenant_id']=$r->user()->isMasterAdmin()?($d['tenant_id']??$r->attributes->get('tenant_id')):$r->attributes->get('tenant_id');$d['created_by']=$r->user()->id;return $this->created(ItemCode::create($d),'Kode barang berhasil dibuat');}
|
||||
public function codeUpdate(Request $r,ItemCode $code){$this->authCode($r,$code);$code->update($this->validateCode($r,$code));return $this->updated($code->fresh(),'Kode barang berhasil diperbarui');}
|
||||
public function codeDestroy(Request $r,ItemCode $code){$this->authCode($r,$code);abort_if($code->items()->exists(),422,'Kode barang yang sudah digunakan tidak dapat dihapus.');$code->delete();return $this->deleted('Kode barang berhasil dihapus');}
|
||||
private function validateCode(Request $r,?ItemCode $code=null){$tenant=$r->input('tenant_id')?:$r->attributes->get('tenant_id');return $r->validate(['tenant_id'=>['nullable','integer','exists:tenants,id'],'code'=>['required','string','max:80',Rule::unique('item_codes')->where('tenant_id',$tenant)->ignore($code)],'name'=>['required','string','max:255'],'tracking_type'=>['required',Rule::in(['serialized','quantity'])],'need_serial_number'=>['boolean'],'unit'=>['required',Rule::in(['pcs','meter','lembar','roll','box','dus','set','unit'])],'minimum_stock'=>['nullable','numeric','min:0'],'description'=>['nullable','string'],'is_active'=>['boolean']]);}
|
||||
private function validateItem(Request $r,?WarehouseItem $i=null){$tenant=$r->user()->isMasterAdmin()?($r->input('tenant_id')?:$r->attributes->get('tenant_id')):$r->attributes->get('tenant_id');$d=$r->validate(['tenant_id'=>['nullable','integer','exists:tenants,id'],'item_code_id'=>['required','integer','exists:item_codes,id',Rule::unique('warehouse_items')->where('tenant_id',$tenant)->ignore($i)],'is_active'=>['boolean']]);$code=ItemCode::findOrFail($d['item_code_id']);abort_unless((int)$code->tenant_id===(int)$tenant,422,'Kode barang bukan milik tenant aktif.');return [...$d,'sku'=>$code->code,'name'=>$code->name,'tracking_type'=>$code->tracking_type==='serialized'?'serialized':'bulk','unit'=>$code->unit,'minimum_stock'=>$code->minimum_stock];}
|
||||
private function authCode(Request $r,ItemCode $code){abort_unless($r->user()->isMasterAdmin()||(int)$code->tenant_id===(int)$r->attributes->get('tenant_id'),403);}
|
||||
private function auth(Request $r,WarehouseItem $i){abort_unless($r->user()->isMasterAdmin()||(int)$i->tenant_id===(int)$r->attributes->get('tenant_id'),403);}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Warehouse;
|
||||
use App\Http\Controllers\ApiController;use App\Models\Warehouse;use Illuminate\Http\Request;use Illuminate\Validation\Rule;
|
||||
class WarehouseLocationController extends ApiController
|
||||
{
|
||||
public function index(Request $r){$t=$r->attributes->get('tenant_id');$q=Warehouse::withCount('items')->when(!$r->user()->isMasterAdmin()||$t,fn($x)=>$x->where('tenant_id',$t))->when($r->search,fn($x,$s)=>$x->where(fn($y)=>$y->where('name','ilike',"%$s%")->orWhere('code','ilike',"%$s%")))->orderBy('name')->paginate(min((int)$r->input('per_page',10),100));return $this->successPaginated($q,\Illuminate\Http\Resources\Json\JsonResource::class,'Daftar gudang berhasil diambil');}
|
||||
public function store(Request $r){$d=$this->data($r);$d['tenant_id']=$r->user()->isMasterAdmin()?($d['tenant_id']??$r->attributes->get('tenant_id')):$r->attributes->get('tenant_id');$d['created_by']=$r->user()->id;return $this->created(Warehouse::create($d),'Gudang berhasil dibuat');}
|
||||
public function update(Request $r,Warehouse $warehouse){$this->auth($r,$warehouse);$warehouse->update($this->data($r,$warehouse));return $this->updated($warehouse->fresh(),'Gudang berhasil diperbarui');}
|
||||
public function destroy(Request $r,Warehouse $warehouse){$this->auth($r,$warehouse);abort_if($warehouse->items()->exists(),422,'Gudang yang sudah memiliki barang tidak dapat dihapus.');$warehouse->delete();return $this->deleted('Gudang berhasil dihapus');}
|
||||
private function data(Request $r,?Warehouse $w=null){$t=$r->input('tenant_id')?:$r->attributes->get('tenant_id');return $r->validate(['tenant_id'=>['nullable','integer','exists:tenants,id'],'code'=>['required','string','max:50',Rule::unique('warehouses')->where('tenant_id',$t)->ignore($w)],'name'=>['required','string','max:255'],'address'=>['nullable','string'],'is_active'=>['boolean']]);}
|
||||
private function auth(Request $r,Warehouse $w){abort_unless($r->user()->isMasterAdmin()||(int)$w->tenant_id===(int)$r->attributes->get('tenant_id'),403);}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests\Customer;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ActivateCustomerRequest extends FormRequest
|
||||
{
|
||||
@@ -14,11 +15,16 @@ class ActivateCustomerRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'activation_source' => ['required', Rule::in(['list_order', 'ticket'])],
|
||||
'package_profile_id' => ['required', 'integer', 'exists:nas_package_profiles,id'],
|
||||
'nas_mikrotik_id' => ['required', 'integer', 'exists:nas_mikrotiks,id'],
|
||||
'billing_profile_id' => ['required', 'integer', 'exists:billing_profiles,id'],
|
||||
'topology_id' => ['nullable', 'integer', 'min:1'],
|
||||
'external_username' => ['nullable', 'string', 'max:255'],
|
||||
'external_username' => ['required', 'string', 'max:255'],
|
||||
'external_password' => ['required', 'string', 'min:4', 'max:255'],
|
||||
'mac_address' => ['nullable', 'regex:/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/'],
|
||||
'serial_number' => ['nullable', 'string', 'max:120'],
|
||||
'assigned_ip' => ['nullable', 'ip'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ class StoreCustomerRequest extends FormRequest
|
||||
return [
|
||||
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
|
||||
'user_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'customer_code' => ['nullable', 'string', 'max:100'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'identity_number' => ['nullable', 'string', 'max:100'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
@@ -32,10 +31,10 @@ class StoreCustomerRequest extends FormRequest
|
||||
'package_profile_id' => ['nullable', 'integer', 'exists:nas_package_profiles,id'],
|
||||
'nas_mikrotik_id' => ['nullable', 'integer', 'exists:nas_mikrotiks,id'],
|
||||
'billing_profile_id' => ['nullable', 'integer', 'min:1'],
|
||||
'topology_id' => ['nullable', 'integer', 'min:1'],
|
||||
'external_username' => ['nullable', 'string', 'max:255'],
|
||||
'topology_id' => ['nullable', 'integer', 'exists:topology_nodes,id'],
|
||||
'topology_port_number' => ['nullable', 'required_with:topology_id', 'integer', 'min:1'],
|
||||
'sales_user_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'source' => ['nullable', Rule::in(['manual', 'mikrotik', 'radius'])],
|
||||
'order_stage' => ['nullable', Rule::in(['registration', 'data_completion', 'ready_activation', 'activation_failed'])],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'images' => ['nullable', 'array', 'max:20'],
|
||||
'images.*.stored_file_id' => ['required', 'integer', 'exists:stored_files,id'],
|
||||
|
||||
@@ -49,6 +49,7 @@ class SyncMenuGroupMenusRequest extends FormRequest
|
||||
'nullable',
|
||||
'boolean',
|
||||
],
|
||||
'menus.*.can_activate' => ['nullable','boolean'],
|
||||
'menus.*.can_export' => [
|
||||
'nullable',
|
||||
'boolean',
|
||||
|
||||
@@ -18,7 +18,7 @@ class IndexNasResourceRequest extends FormRequest
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', Rule::in(['active', 'inactive'])],
|
||||
'connection_type' => ['nullable', Rule::in(['api', 'radius'])],
|
||||
'service_type' => ['nullable', Rule::in(['ppp', 'hotspot', 'radius'])],
|
||||
'service_type' => ['nullable', Rule::in(['ppp', 'hotspot', 'static_ip', 'dynamic_ip'])],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'sort_by' => ['nullable', Rule::in(['id', 'name', 'host', 'connection_type', 'service_type', 'vendor', 'device_type', 'status', 'created_at'])],
|
||||
|
||||
@@ -17,17 +17,42 @@ class StoreNasResourceRequest extends FormRequest
|
||||
return array_merge($this->commonRules(), match ($this->route()->defaults['nas_resource'] ?? null) {
|
||||
'mikrotik' => [
|
||||
'connection_type' => ['required', Rule::in(['api', 'radius'])],
|
||||
'backend_mode_override' => ['required', Rule::in(['routeros_api','platform_radius','external_radius'])],
|
||||
'host' => ['required', 'string', 'max:255'],
|
||||
'nas_identifier' => ['nullable', 'string', 'max:255'],
|
||||
'nas_ip_address' => ['nullable', 'ip'],
|
||||
'radius_host' => ['nullable', 'string', 'max:255'],
|
||||
'api_port' => ['nullable', 'integer', 'between:1,65535'],
|
||||
'api_username' => ['nullable', 'string', 'max:255'],
|
||||
'api_password' => ['nullable', 'string', 'max:1000'],
|
||||
'radius_auth_port' => ['nullable', 'integer', 'between:1,65535'],
|
||||
'radius_accounting_port' => ['nullable', 'integer', 'between:1,65535'],
|
||||
'radius_coa_port' => ['nullable', 'integer', 'between:1,65535'],
|
||||
'radius_secret' => ['nullable', 'string', 'max:1000'],
|
||||
'timeout' => ['nullable', 'integer', 'between:1,300'],
|
||||
],
|
||||
'package-profile' => [
|
||||
'service_type' => ['required', Rule::in(['ppp', 'hotspot', 'radius'])],
|
||||
'service_type' => ['required', Rule::in(['ppp', 'hotspot', 'static_ip', 'dynamic_ip'])],
|
||||
'profile_code' => ['required', 'alpha_dash', 'max:80'],
|
||||
'usage_mode' => ['nullable', Rule::in(['one_time', 'periodic', 'recurring_member'])],
|
||||
'validity_start' => ['nullable', Rule::in(['activation', 'first_login'])],
|
||||
'validity_value' => ['nullable', 'integer', 'min:1'],
|
||||
'validity_unit' => ['nullable', Rule::in(['minutes', 'hours', 'days', 'months'])],
|
||||
'uptime_limit_seconds' => ['nullable', 'integer', 'min:0'],
|
||||
'data_limit_bytes' => ['nullable', 'integer', 'min:0'],
|
||||
'grace_period_minutes' => ['nullable', 'integer', 'min:0', 'max:10080'],
|
||||
'mac_lock_required' => ['nullable', 'boolean'],
|
||||
'ip_pool' => ['nullable', 'string', 'max:255'],
|
||||
'subnet_cidr' => ['nullable', 'string', 'max:64'],
|
||||
'gateway' => ['nullable', 'ip'],
|
||||
'dns_servers' => ['nullable', 'array', 'max:4'],
|
||||
'dns_servers.*' => ['required', 'ip'],
|
||||
'vlan_id' => ['nullable', 'integer', 'between:1,4094'],
|
||||
'service_parameters' => ['nullable', 'array'],
|
||||
'scripts' => ['nullable', 'array'],
|
||||
'scripts.*.policy_id' => ['required', 'integer', 'exists:nas_script_policies,id'],
|
||||
'scripts.*.version_id' => ['nullable', 'integer', 'exists:nas_script_policy_versions,id'],
|
||||
'scripts.*.event' => ['required', Rule::in(['on_up','on_down','on_login','on_logout'])],
|
||||
'external_profile_name' => ['nullable', 'string', 'max:255'],
|
||||
'download_kbps' => ['nullable', 'integer', 'min:0'],
|
||||
'upload_kbps' => ['nullable', 'integer', 'min:0'],
|
||||
@@ -72,4 +97,42 @@ class StoreNasResourceRequest extends FormRequest
|
||||
'notes' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$resource = $this->route()->defaults['nas_resource'] ?? null;
|
||||
if ($resource === 'mikrotik') {
|
||||
$validator->after(function ($validator) {
|
||||
$mode = $this->input('backend_mode_override');
|
||||
if (! $this->input('api_username')) $validator->errors()->add('api_username', 'API Username wajib diisi untuk monitoring Mikrotik.');
|
||||
if ($this->isMethod('post') && ! $this->input('api_password')) $validator->errors()->add('api_password', 'API Password wajib diisi.');
|
||||
if ($mode !== 'routeros_api') {
|
||||
if (! $this->input('nas_identifier')) $validator->errors()->add('nas_identifier', 'NAS Identifier wajib diisi untuk integrasi RADIUS.');
|
||||
if (! $this->input('nas_ip_address')) $validator->errors()->add('nas_ip_address', 'IP NAS wajib diisi untuk integrasi RADIUS.');
|
||||
if ($this->isMethod('post') && ! $this->input('radius_secret')) $validator->errors()->add('radius_secret', 'Shared secret khusus NAS wajib diisi.');
|
||||
if ($mode === 'external_radius' && ! $this->input('radius_host')) $validator->errors()->add('radius_host', 'Host RADIUS Eksternal wajib diisi.');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if ($resource !== 'package-profile') return;
|
||||
|
||||
$validator->after(function ($validator) {
|
||||
$type = $this->input('service_type');
|
||||
$mode = $this->input('usage_mode');
|
||||
if ($type === 'hotspot' && ! in_array($mode, ['one_time', 'periodic', 'recurring_member'], true)) {
|
||||
$validator->errors()->add('usage_mode', 'Mode pemakaian wajib dipilih untuk profile Hotspot.');
|
||||
}
|
||||
if ($type === 'hotspot' && in_array($mode, ['one_time', 'periodic', 'recurring_member'], true)
|
||||
&& (! $this->input('validity_value') || ! $this->input('validity_unit'))) {
|
||||
$validator->errors()->add('validity_value', 'Durasi dan satuan masa aktif wajib diisi untuk profile Hotspot.');
|
||||
}
|
||||
if (in_array($type, ['static_ip', 'dynamic_ip'], true)) {
|
||||
if (! $this->boolean('mac_lock_required')) $validator->errors()->add('mac_lock_required', 'Penguncian MAC wajib aktif untuk profile IP Static/Dinamis.');
|
||||
if (! $this->input('subnet_cidr')) $validator->errors()->add('subnet_cidr', 'Subnet CIDR wajib diisi.');
|
||||
if (! $this->input('gateway')) $validator->errors()->add('gateway', 'Gateway wajib diisi.');
|
||||
if ($type === 'dynamic_ip' && ! $this->input('ip_pool')) $validator->errors()->add('ip_pool', 'IP Pool wajib diisi untuk IP Dinamis.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ class AssignTicketRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'technicians' => 'required|array|min:1',
|
||||
'leader_id' => 'required|integer',
|
||||
'technicians.*' => 'required|integer|exists:users,id',
|
||||
'leader_id' => 'required|integer|in_array:technicians.*',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ class IndexTicketRequest extends FormRequest
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
/** Jumlah data per halaman, maksimal 100. */
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'sort_by' => ['nullable', Rule::in(['ticket_no', 'title', 'priority', 'status', 'created_at'])],
|
||||
'sort_direction' => ['nullable', Rule::in(['asc', 'desc'])],
|
||||
'assigned_to_me' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ class PendingTicketRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'reason' => 'required|string|max:1000'
|
||||
'reason' => 'required|string|max:1000',
|
||||
'stored_file_ids' => ['required','array','min:1'],
|
||||
'stored_file_ids.*' => ['integer','exists:stored_files,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ class ResolveTicketRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'notes' => 'required|string|max:2000'
|
||||
'notes' => 'required|string|max:2000',
|
||||
'stored_file_ids' => ['required','array','min:1'],
|
||||
'stored_file_ids.*' => ['integer','exists:stored_files,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ class StartTicketRequest extends FormRequest
|
||||
return [
|
||||
'latitude' => 'nullable|numeric',
|
||||
'longitude' => 'nullable|numeric',
|
||||
'notes' => 'nullable|string|max:1000'
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'stored_file_ids' => ['required','array','min:1'],
|
||||
'stored_file_ids.*' => ['integer','exists:stored_files,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,9 @@ class StoreTicketRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ticket_type_id' => 'required|integer',
|
||||
'customer_id' => 'nullable|integer',
|
||||
'tenant_id' => 'nullable|integer|exists:tenants,id',
|
||||
'ticket_type_id' => 'required|integer|exists:ticket_types,id',
|
||||
'customer_id' => 'nullable|integer|exists:customers,id',
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'priority' => 'required|in:low,medium,high,critical'
|
||||
|
||||
@@ -10,6 +10,7 @@ class NasResource extends JsonResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$data = $this->resource->attributesToArray();
|
||||
if ($this->resource instanceof \App\Models\NasMikrotik) $data['effective_backend_mode'] = $this->resource->effectiveBackendMode();
|
||||
unset($data['api_password'], $data['radius_secret'], $data['community'], $data['auth_password'], $data['privacy_password'], $data['password']);
|
||||
|
||||
$data['tenant'] = $this->whenLoaded('tenant', fn () => [
|
||||
@@ -17,6 +18,23 @@ class NasResource extends JsonResource
|
||||
'tenant_code' => $this->tenant->tenant_code,
|
||||
'tenant_name' => $this->tenant->tenant_name,
|
||||
]);
|
||||
$data['scripts'] = $this->whenLoaded('scripts', fn () => $this->scripts->map(fn ($policy) => [
|
||||
'id' => $policy->id,
|
||||
'code' => $policy->code,
|
||||
'name' => $policy->name,
|
||||
'service_type' => $policy->service_type,
|
||||
'event' => $policy->event,
|
||||
'current_version' => $policy->current_version,
|
||||
'latest_version' => $policy->relationLoaded('latestVersion') && $policy->latestVersion ? [
|
||||
'id' => $policy->latestVersion->id,
|
||||
'version' => $policy->latestVersion->version,
|
||||
] : null,
|
||||
'pivot' => [
|
||||
'event' => $policy->pivot->event,
|
||||
'nas_script_policy_version_id' => $policy->pivot->nas_script_policy_version_id,
|
||||
'enabled' => (bool) $policy->pivot->enabled,
|
||||
],
|
||||
]));
|
||||
|
||||
foreach ([
|
||||
'api_password' => 'has_api_password',
|
||||
|
||||
@@ -23,9 +23,26 @@ class TicketResource extends JsonResource
|
||||
'description' => $this->description,
|
||||
'priority' => $this->priority,
|
||||
'status' => $this->status,
|
||||
'source' => $this->source,
|
||||
'installation_step' => $this->installation_step,
|
||||
'verified_by' => $this->verified_by,
|
||||
'verified_at' => $this->verified_at,
|
||||
'verification_notes' => $this->verification_notes,
|
||||
'created_by' => $this->created_by,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
'tenant_id' => $this->tenant_id,
|
||||
'sla_minutes' => $this->sla_minutes,
|
||||
'assigned_at' => $this->assigned_at,
|
||||
'resolved_at' => $this->resolved_at,
|
||||
'closed_at' => $this->closed_at,
|
||||
'ticket_type' => $this->whenLoaded('ticketType'),
|
||||
'tenant' => $this->whenLoaded('tenant'),
|
||||
'customer' => $this->whenLoaded('customer'),
|
||||
'creator' => $this->whenLoaded('creator'),
|
||||
'assignments' => $this->whenLoaded('assignments'),
|
||||
'materials' => $this->whenLoaded('materials'),
|
||||
'attachments' => $this->whenLoaded('attachments'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\NasMikrotik;
|
||||
use App\Services\Nas\MikrotikHealthService;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
class CheckMikrotikConnection implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 25;
|
||||
|
||||
public int $uniqueFor = 240;
|
||||
|
||||
public function __construct(public int $mikrotikId)
|
||||
{
|
||||
$this->onQueue(config('nas.health.queue', 'nas-health'));
|
||||
}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->mikrotikId;
|
||||
}
|
||||
|
||||
public function handle(MikrotikHealthService $health): void
|
||||
{
|
||||
$mikrotik = NasMikrotik::whereKey($this->mikrotikId)->where('status', 'active')->first();
|
||||
if (! $mikrotik) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$health->check($mikrotik);
|
||||
} catch (Throwable) {
|
||||
// Health service has persisted the offline state and next backoff time.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
namespace App\Jobs;
|
||||
use App\Models\CustomerNasAccount;
|
||||
use App\Services\Nas\CustomerAccountReconciliationService;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
class ReconcileCustomerNasAccount implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
use Queueable; public int $tries=1; public int $timeout=30; public int $uniqueFor=240;
|
||||
public function __construct(public int $accountId){$this->onQueue('nas-reconciliation');}
|
||||
public function uniqueId(): string{return (string)$this->accountId;}
|
||||
public function handle(CustomerAccountReconciliationService $service): void { $a=CustomerNasAccount::find($this->accountId); if(!$a)return; try{$service->reconcile($a);}catch(Throwable $e){$a->update(['last_reconciled_at'=>now(),'last_sync_error'=>$e->getMessage()]);} }
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CustomerNasAccount extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $hidden = ['password'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'password' => 'encrypted',
|
||||
'last_synced_at' => 'datetime',
|
||||
'validity_started_at' => 'datetime', 'first_login_at' => 'datetime', 'expires_at' => 'datetime',
|
||||
'last_reconciled_at' => 'datetime', 'remote_state' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function mikrotik()
|
||||
{
|
||||
return $this->belongsTo(NasMikrotik::class, 'nas_mikrotik_id');
|
||||
}
|
||||
|
||||
public function packageProfile()
|
||||
{
|
||||
return $this->belongsTo(NasPackageProfile::class, 'nas_package_profile_id');
|
||||
}
|
||||
|
||||
public function packageProfileTarget()
|
||||
{
|
||||
return $this->belongsTo(NasPackageProfileTarget::class, 'nas_package_profile_target_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class ItemBarcode extends Model { protected $guarded=['id']; protected function casts():array{return ['initial_qty'=>'decimal:2','remaining_qty'=>'decimal:2'];} protected static function booted():void{static::updated(function(self $barcode){if(!$barcode->wasChanged('remaining_qty'))return;$item=$barcode->item;if(!$item||$item->status==='draft')return;$remaining=(float)$item->barcodes()->sum('remaining_qty');$item->update(['status'=>$remaining>0?'active':'depleted','is_active'=>$remaining>0]);});} public function item(){return $this->belongsTo(WarehouseItem::class,'warehouse_item_id');} }
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ItemCode extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['need_serial_number' => 'boolean', 'minimum_stock' => 'decimal:2', 'is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function items() { return $this->hasMany(WarehouseItem::class); }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class ItemMovement extends Model { protected $guarded=['id']; protected function casts():array{return ['qty'=>'decimal:2'];} }
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class ItemStockLog extends Model { protected $guarded=['id']; protected function casts():array{return ['qty_before'=>'decimal:2','qty_change'=>'decimal:2','qty_after'=>'decimal:2','barcode_qty_before'=>'decimal:2','barcode_qty_change'=>'decimal:2','barcode_qty_after'=>'decimal:2'];} }
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class NasPackageProfileTarget extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
protected function casts(): array { return ['desired_snapshot' => 'array', 'remote_snapshot' => 'array', 'last_checked_at' => 'datetime', 'last_synced_at' => 'datetime']; }
|
||||
public function packageProfile() { return $this->belongsTo(NasPackageProfile::class, 'nas_package_profile_id'); }
|
||||
public function mikrotik() { return $this->belongsTo(NasMikrotik::class, 'nas_mikrotik_id'); }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class NasScriptPolicy extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
protected function casts(): array { return ['allowed_variables' => 'array', 'enabled' => 'boolean']; }
|
||||
public function versions() { return $this->hasMany(NasScriptPolicyVersion::class); }
|
||||
public function latestVersion() { return $this->hasOne(NasScriptPolicyVersion::class)->ofMany('version', 'max'); }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class NasScriptPolicyVersion extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
public function policy() { return $this->belongsTo(NasScriptPolicy::class, 'nas_script_policy_id'); }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class RadiusCluster extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
protected $hidden = ['provisioning_api_token'];
|
||||
protected function casts(): array { return ['auth_endpoints'=>'array','accounting_endpoints'=>'array','coa_endpoints'=>'array','provisioning_api_token'=>'encrypted','last_checked_at'=>'datetime']; }
|
||||
}
|
||||
@@ -21,4 +21,6 @@ class TechnicianConsumable extends Model
|
||||
'assigned_at',
|
||||
'assigned_by'
|
||||
];
|
||||
}
|
||||
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
}
|
||||
|
||||
@@ -18,4 +18,6 @@ class TechnicianMaterial extends Model
|
||||
'assigned_at',
|
||||
'assigned_by',
|
||||
];
|
||||
}
|
||||
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Models;use Illuminate\Database\Eloquent\Model;
|
||||
class TenantInstallationSetting extends Model{protected $fillable=['tenant_id','activation_mode','ktp_ocr_enabled','ocr_provider','ocr_api_key'];protected $hidden=['ocr_api_key'];protected function casts():array{return['ktp_ocr_enabled'=>'boolean','ocr_api_key'=>'encrypted'];}public function tenant(){return $this->belongsTo(Tenant::class);}}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class TenantNasSetting extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
protected $hidden = ['external_radius_secret','external_provisioning_api_token'];
|
||||
protected function casts(): array { return ['external_radius_secret'=>'encrypted','external_provisioning_api_token'=>'encrypted','accounting_enabled'=>'boolean','coa_enabled'=>'boolean','last_checked_at'=>'datetime']; }
|
||||
public function tenant() { return $this->belongsTo(Tenant::class); }
|
||||
public function radiusCluster() { return $this->belongsTo(RadiusCluster::class); }
|
||||
}
|
||||
@@ -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'); }
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class TicketAttachment extends Model { protected $guarded=['id']; public function file(){return $this->belongsTo(StoredFile::class,'stored_file_id');} public function uploader(){return $this->belongsTo(User::class,'uploaded_by');} }
|
||||
@@ -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');}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ class UserTenant extends Model
|
||||
'user_id',
|
||||
'tenant_id',
|
||||
'role',
|
||||
'department',
|
||||
'is_default',
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class Warehouse extends Model { protected $guarded=['id']; protected function casts():array{return ['is_active'=>'boolean'];} public function items(){return $this->hasMany(WarehouseItem::class);} }
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class WarehouseItem extends Model { protected $guarded=['id']; protected function casts():array{return ['is_active'=>'boolean','minimum_stock'=>'decimal:2','received_qty'=>'decimal:2','received_at'=>'date','posted_at'=>'datetime'];} public function warehouse(){return $this->belongsTo(Warehouse::class);} public function itemCode(){return $this->belongsTo(ItemCode::class);} public function barcodes(){return $this->hasMany(ItemBarcode::class);} public function movements(){return $this->hasMany(ItemMovement::class)->latest();} }
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -119,6 +119,7 @@ class MenuGroupService
|
||||
DB::raw('COALESCE(mgm.can_update, false) as can_update'),
|
||||
DB::raw('COALESCE(mgm.can_delete, false) as can_delete'),
|
||||
DB::raw('COALESCE(mgm.can_approve, false) as can_approve'),
|
||||
DB::raw('COALESCE(mgm.can_activate, false) as can_activate'),
|
||||
DB::raw('COALESCE(mgm.can_export, false) as can_export')
|
||||
)
|
||||
->orderBy('m.sort_order')
|
||||
@@ -141,6 +142,7 @@ class MenuGroupService
|
||||
'can_update' => (bool) $row->can_update,
|
||||
'can_delete' => (bool) $row->can_delete,
|
||||
'can_approve' => (bool) $row->can_approve,
|
||||
'can_activate' => (bool) $row->can_activate,
|
||||
'can_export' => (bool) $row->can_export,
|
||||
],
|
||||
'available_permissions' => [
|
||||
@@ -149,6 +151,7 @@ class MenuGroupService
|
||||
'can_update' => true,
|
||||
'can_delete' => true,
|
||||
'can_approve' => true,
|
||||
'can_activate' => true,
|
||||
'can_export' => true,
|
||||
],
|
||||
];
|
||||
@@ -191,6 +194,7 @@ class MenuGroupService
|
||||
'can_update' => (bool) ($menu['can_update'] ?? false),
|
||||
'can_delete' => (bool) ($menu['can_delete'] ?? false),
|
||||
'can_approve' => (bool) ($menu['can_approve'] ?? false),
|
||||
'can_activate' => (bool) ($menu['can_activate'] ?? false),
|
||||
'can_export' => (bool) ($menu['can_export'] ?? false),
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
@@ -229,6 +233,7 @@ class MenuGroupService
|
||||
DB::raw('COALESCE(mgm.can_update, false) as can_update'),
|
||||
DB::raw('COALESCE(mgm.can_delete, false) as can_delete'),
|
||||
DB::raw('COALESCE(mgm.can_approve, false) as can_approve'),
|
||||
DB::raw('COALESCE(mgm.can_activate, false) as can_activate'),
|
||||
DB::raw('COALESCE(mgm.can_export, false) as can_export')
|
||||
)
|
||||
->orderBy('m.sort_order')
|
||||
@@ -249,6 +254,7 @@ class MenuGroupService
|
||||
'can_update' => (bool) $row->can_update,
|
||||
'can_delete' => (bool) $row->can_delete,
|
||||
'can_approve' => (bool) $row->can_approve,
|
||||
'can_activate' => (bool) $row->can_activate,
|
||||
'can_export' => (bool) $row->can_export,
|
||||
],
|
||||
'available_permissions' => $available,
|
||||
@@ -278,6 +284,7 @@ class MenuGroupService
|
||||
->orWhere('mgm.can_update', true)
|
||||
->orWhere('mgm.can_delete', true)
|
||||
->orWhere('mgm.can_approve', true)
|
||||
->orWhere('mgm.can_activate', true)
|
||||
->orWhere('mgm.can_export', true);
|
||||
})
|
||||
->distinct()
|
||||
@@ -304,6 +311,7 @@ class MenuGroupService
|
||||
DB::raw('MAX(CASE WHEN mgm.can_update THEN 1 ELSE 0 END) as can_update'),
|
||||
DB::raw('MAX(CASE WHEN mgm.can_delete THEN 1 ELSE 0 END) as can_delete'),
|
||||
DB::raw('MAX(CASE WHEN mgm.can_approve THEN 1 ELSE 0 END) as can_approve'),
|
||||
DB::raw('MAX(CASE WHEN mgm.can_activate THEN 1 ELSE 0 END) as can_activate'),
|
||||
DB::raw('MAX(CASE WHEN mgm.can_export THEN 1 ELSE 0 END) as can_export')
|
||||
)
|
||||
->get()
|
||||
@@ -313,6 +321,7 @@ class MenuGroupService
|
||||
'can_update' => (bool) $row->can_update,
|
||||
'can_delete' => (bool) $row->can_delete,
|
||||
'can_approve' => (bool) $row->can_approve,
|
||||
'can_activate' => (bool) $row->can_activate,
|
||||
'can_export' => (bool) $row->can_export,
|
||||
]])
|
||||
->all();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
namespace App\Services\Nas;
|
||||
use App\Models\CustomerNasAccount;
|
||||
use App\Models\NasPackageProfile;
|
||||
|
||||
class CustomerAccountReconciliationService
|
||||
{
|
||||
public function __construct(protected RouterOsApiService $routerOs) {}
|
||||
public function reconcile(CustomerNasAccount $account): void
|
||||
{
|
||||
$account->load(['mikrotik','packageProfile']);
|
||||
if ($account->mikrotik->connection_type !== 'api') return;
|
||||
$state = $this->routerOs->inspectAccount($account->mikrotik,$account->service_type,$account->username);
|
||||
$active = $state['active'];
|
||||
$uptime = max(
|
||||
$this->durationSeconds($active['uptime'] ?? ''),
|
||||
$this->durationSeconds($state['account']['uptime'] ?? '')
|
||||
);
|
||||
$bytes = max(
|
||||
(int) ($active['bytes-in'] ?? 0) + (int) ($active['bytes-out'] ?? 0),
|
||||
(int) ($state['account']['bytes-in'] ?? 0) + (int) ($state['account']['bytes-out'] ?? 0)
|
||||
);
|
||||
$updates=['remote_state'=>$state,'used_uptime_seconds'=>$uptime,'used_bytes'=>$bytes,'last_reconciled_at'=>now(),'last_sync_error'=>null];
|
||||
if ($active && ! $account->first_login_at) {
|
||||
$updates['first_login_at']=now();
|
||||
if ($account->packageProfile->validity_start === 'first_login') { $updates['validity_started_at']=now(); $updates['expires_at']=$this->expiry($account->packageProfile); }
|
||||
}
|
||||
$expires=$updates['expires_at'] ?? $account->expires_at;
|
||||
$expiresWithGrace = $expires?->copy()->addMinutes((int) $account->packageProfile->grace_period_minutes);
|
||||
$limitReached=($expiresWithGrace && now()->gte($expiresWithGrace)) || ($account->packageProfile->uptime_limit_seconds && $uptime >= $account->packageProfile->uptime_limit_seconds) || ($account->packageProfile->data_limit_bytes && $bytes >= $account->packageProfile->data_limit_bytes);
|
||||
if ($limitReached && $account->status === 'active') {
|
||||
$this->routerOs->setEnabled($account->mikrotik,$account->service_type,$account->username,false);
|
||||
$this->routerOs->disconnectAccount($account->mikrotik,$account->service_type,$account->username);
|
||||
$updates['status']='expired';
|
||||
}
|
||||
$account->update($updates);
|
||||
}
|
||||
private function durationSeconds(string $value): int { preg_match_all('/(\d+)(w|d|h|m|s)/',$value,$m,PREG_SET_ORDER); $map=['w'=>604800,'d'=>86400,'h'=>3600,'m'=>60,'s'=>1]; return array_sum(array_map(fn($x)=>(int)$x[1]*$map[$x[2]],$m)); }
|
||||
private function expiry(NasPackageProfile $p) { if(!$p->validity_value||!$p->validity_unit)return null; return match($p->validity_unit){'minutes'=>now()->addMinutes($p->validity_value),'hours'=>now()->addHours($p->validity_value),'days'=>now()->addDays($p->validity_value),'months'=>now()->addMonths($p->validity_value),default=>null}; }
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Nas;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerNasAccount;
|
||||
use App\Models\NasMikrotik;
|
||||
use App\Models\NasPackageProfile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class CustomerRouterOsProvisioningService
|
||||
{
|
||||
public function __construct(
|
||||
protected RouterOsApiService $routerOs,
|
||||
protected PackageProfileDeploymentService $deployments,
|
||||
) {}
|
||||
|
||||
public function create(Customer $customer, array $data): CustomerNasAccount
|
||||
{
|
||||
[$mikrotik, $profile] = $this->resources($customer, $data);
|
||||
if ($mikrotik->connection_type !== 'api') {
|
||||
throw ValidationException::withMessages(['nas_mikrotik_id' => 'Tahap ini hanya mendukung Mikrotik dengan koneksi API.']);
|
||||
}
|
||||
$target = $this->deployments->assertSynced($profile, $mikrotik);
|
||||
|
||||
$username = trim((string) ($data['external_username'] ?? ''));
|
||||
$password = (string) ($data['external_password'] ?? '');
|
||||
if ($username === '' || $password === '') {
|
||||
throw ValidationException::withMessages(['external_username' => 'Username dan password RouterOS wajib diisi.']);
|
||||
}
|
||||
|
||||
$remote = $this->routerOs->createAccount($mikrotik, $profile->service_type, [
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'profile' => $target->external_profile_name,
|
||||
'comment' => $this->comment($customer),
|
||||
'disabled' => 'no',
|
||||
]);
|
||||
|
||||
try {
|
||||
return DB::transaction(fn () => CustomerNasAccount::create([
|
||||
'tenant_id' => $customer->tenant_id,
|
||||
'customer_id' => $customer->id,
|
||||
'nas_mikrotik_id' => $mikrotik->id,
|
||||
'nas_package_profile_id' => $profile->id,
|
||||
'nas_package_profile_target_id' => $target->id,
|
||||
'service_type' => $profile->service_type,
|
||||
'usage_mode' => $profile->usage_mode,
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'mac_address' => $data['mac_address'] ?? null,
|
||||
'serial_number' => $data['serial_number'] ?? null,
|
||||
'assigned_ip' => $data['assigned_ip'] ?? null,
|
||||
'remote_id' => $remote['.id'] ?? null,
|
||||
'remote_profile' => $target->external_profile_name,
|
||||
'status' => 'active',
|
||||
'validity_started_at' => $profile->validity_start === 'activation' ? now() : null,
|
||||
'expires_at' => $profile->validity_start === 'activation' ? $this->expiresAt($profile) : null,
|
||||
'last_synced_at' => now(),
|
||||
]));
|
||||
} catch (Throwable $exception) {
|
||||
try {
|
||||
$this->routerOs->deleteAccount($mikrotik, $profile->service_type, $username);
|
||||
} catch (Throwable) {
|
||||
// Compensating delete is best effort; original database error remains authoritative.
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function setEnabled(Customer $customer, bool $enabled): CustomerNasAccount
|
||||
{
|
||||
$account = $this->account($customer);
|
||||
$remote = $this->routerOs->setEnabled(
|
||||
$account->mikrotik,
|
||||
$account->service_type,
|
||||
$account->username,
|
||||
$enabled,
|
||||
);
|
||||
$account->update([
|
||||
'remote_id' => $remote['.id'] ?? $account->remote_id,
|
||||
'status' => $enabled ? 'active' : 'disabled',
|
||||
'last_synced_at' => now(),
|
||||
'last_sync_error' => null,
|
||||
]);
|
||||
|
||||
return $account->fresh();
|
||||
}
|
||||
|
||||
public function update(Customer $customer, array $attributes): CustomerNasAccount
|
||||
{
|
||||
$account = $this->account($customer);
|
||||
$remote = $this->routerOs->updateAccount(
|
||||
$account->mikrotik,
|
||||
$account->service_type,
|
||||
$account->username,
|
||||
$attributes,
|
||||
);
|
||||
$account->update([
|
||||
'username' => $attributes['username'] ?? $account->username,
|
||||
'password' => $attributes['password'] ?? $account->password,
|
||||
'remote_profile' => $attributes['profile'] ?? $account->remote_profile,
|
||||
'remote_id' => $remote['.id'] ?? $account->remote_id,
|
||||
'last_synced_at' => now(),
|
||||
'last_sync_error' => null,
|
||||
]);
|
||||
|
||||
return $account->fresh();
|
||||
}
|
||||
|
||||
public function delete(Customer $customer): void
|
||||
{
|
||||
$account = $customer->nasAccount()->with('mikrotik')->first();
|
||||
if (! $account) {
|
||||
return;
|
||||
}
|
||||
$this->routerOs->deleteAccount($account->mikrotik, $account->service_type, $account->username);
|
||||
$account->delete();
|
||||
}
|
||||
|
||||
private function resources(Customer $customer, array $data): array
|
||||
{
|
||||
$mikrotik = NasMikrotik::whereKey($data['nas_mikrotik_id'])
|
||||
->where('tenant_id', $customer->tenant_id)->firstOrFail();
|
||||
$profile = NasPackageProfile::whereKey($data['package_profile_id'])
|
||||
->where('tenant_id', $customer->tenant_id)->firstOrFail();
|
||||
|
||||
return [$mikrotik, $profile];
|
||||
}
|
||||
|
||||
private function account(Customer $customer): CustomerNasAccount
|
||||
{
|
||||
$account = $customer->nasAccount()->with('mikrotik')->first();
|
||||
if (! $account) {
|
||||
throw ValidationException::withMessages(['nas_account' => 'Akun RouterOS customer belum tercatat.']);
|
||||
}
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
private function comment(Customer $customer): string
|
||||
{
|
||||
return "ManjaPro | {$customer->customer_code} | {$customer->name}";
|
||||
}
|
||||
|
||||
private function expiresAt(NasPackageProfile $profile): ?Carbon
|
||||
{
|
||||
if (! $profile->validity_value || ! $profile->validity_unit) return null;
|
||||
return match ($profile->validity_unit) {
|
||||
'minutes' => now()->addMinutes($profile->validity_value),
|
||||
'hours' => now()->addHours($profile->validity_value),
|
||||
'days' => now()->addDays($profile->validity_value),
|
||||
'months' => now()->addMonths($profile->validity_value),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Nas;
|
||||
|
||||
use App\Models\NasMikrotik;
|
||||
use Throwable;
|
||||
|
||||
class MikrotikHealthService
|
||||
{
|
||||
public function __construct(
|
||||
protected RouterOsApiService $routerOs,
|
||||
protected RadiusHealthService $radius,
|
||||
) {}
|
||||
|
||||
public function check(NasMikrotik $mikrotik): array
|
||||
{
|
||||
try {
|
||||
$result = $mikrotik->connection_type === 'radius'
|
||||
? $this->radius->testConnection($mikrotik)
|
||||
: $this->routerOs->testConnection($mikrotik);
|
||||
$mikrotik->update([
|
||||
'connection_status' => 'online',
|
||||
'last_checked_at' => now(),
|
||||
'last_connected_at' => now(),
|
||||
'last_latency_ms' => $result['latency_ms'],
|
||||
'consecutive_failures' => 0,
|
||||
'router_identity' => $result['identity'] ?? null,
|
||||
'router_version' => $result['version'] ?? null,
|
||||
'health_details' => $result,
|
||||
'next_health_check_at' => now()->addMinutes(config('nas.health.online_interval_minutes', 5)),
|
||||
'last_connection_error' => null,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (Throwable $exception) {
|
||||
$message = $exception instanceof \Illuminate\Validation\ValidationException
|
||||
? collect($exception->errors())->flatten()->first()
|
||||
: $exception->getMessage();
|
||||
$failures = $mikrotik->consecutive_failures + 1;
|
||||
$mikrotik->update([
|
||||
'connection_status' => 'offline',
|
||||
'last_checked_at' => now(),
|
||||
'consecutive_failures' => $failures,
|
||||
'next_health_check_at' => now()->addMinutes(min(
|
||||
config('nas.health.offline_max_interval_minutes', 30),
|
||||
2 ** min($failures, 5)
|
||||
)),
|
||||
'health_details' => [
|
||||
'connected' => false,
|
||||
'host' => $mikrotik->host,
|
||||
'api_port' => $mikrotik->connection_type === 'api' ? $mikrotik->api_port : null,
|
||||
'auth_port' => $mikrotik->connection_type === 'radius' ? $mikrotik->radius_auth_port : null,
|
||||
'accounting_port' => $mikrotik->connection_type === 'radius' ? $mikrotik->radius_accounting_port : null,
|
||||
],
|
||||
'last_connection_error' => $message,
|
||||
]);
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Models\NasWebfigDevice;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use App\Models\NasScriptPolicy;
|
||||
|
||||
class NasResourceService
|
||||
{
|
||||
@@ -31,7 +32,7 @@ class NasResourceService
|
||||
$model = $this->model($resource);
|
||||
|
||||
return $model::query()
|
||||
->with('tenant:id,tenant_code,tenant_name')
|
||||
->with(['tenant:id,tenant_code,tenant_name', ...($resource === 'mikrotik' ? ['tenant.nasSetting'] : []), ...($resource === 'package-profile' ? ['scripts.latestVersion'] : [])])
|
||||
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status']))
|
||||
@@ -40,7 +41,7 @@ class NasResourceService
|
||||
->when(! empty($filters['search']), function ($q) use ($filters, $resource) {
|
||||
$columns = match ($resource) {
|
||||
'mikrotik' => ['name', 'host', 'api_username'],
|
||||
'package-profile' => ['name', 'external_profile_name'],
|
||||
'package-profile' => ['name', 'profile_code'],
|
||||
'olt' => ['name', 'host', 'vendor', 'model'],
|
||||
'webfig' => ['name', 'url', 'device_type'],
|
||||
};
|
||||
@@ -59,11 +60,15 @@ class NasResourceService
|
||||
{
|
||||
$model = $this->model($resource);
|
||||
|
||||
return $model::with('tenant:id,tenant_code,tenant_name')->findOrFail($id);
|
||||
return $model::with(['tenant:id,tenant_code,tenant_name', ...($resource === 'mikrotik' ? ['tenant.nasSetting'] : []), ...($resource === 'package-profile' ? ['scripts.latestVersion'] : [])])->findOrFail($id);
|
||||
}
|
||||
|
||||
public function create(string $resource, array $data, User $actor, ?int $tenantId): Model
|
||||
{
|
||||
if ($resource === 'mikrotik') $data['connection_type'] = 'api';
|
||||
if ($resource === 'package-profile') $data = $this->normalizePackageProfile($data);
|
||||
$scripts = $resource === 'package-profile' ? ($data['scripts'] ?? []) : [];
|
||||
unset($data['scripts']);
|
||||
$data['tenant_id'] = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
|
||||
if (! $data['tenant_id']) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
@@ -71,11 +76,18 @@ class NasResourceService
|
||||
$data['status'] ??= 'active';
|
||||
$model = $this->model($resource);
|
||||
|
||||
return $model::create($data)->load('tenant:id,tenant_code,tenant_name');
|
||||
$created = $model::create($data);
|
||||
if ($resource === 'package-profile') $this->syncProfileScripts($created, $scripts);
|
||||
return $created->load('tenant:id,tenant_code,tenant_name');
|
||||
}
|
||||
|
||||
public function update(string $resource, Model $model, array $data, User $actor): Model
|
||||
{
|
||||
if ($resource === 'mikrotik') $data['connection_type'] = 'api';
|
||||
if ($resource === 'package-profile') $data = $this->normalizePackageProfile($data, $model);
|
||||
$hasScripts = $resource === 'package-profile' && array_key_exists('scripts', $data);
|
||||
$scripts = $data['scripts'] ?? [];
|
||||
unset($data['scripts']);
|
||||
if (! $actor->isMasterAdmin()) {
|
||||
unset($data['tenant_id']);
|
||||
}
|
||||
@@ -85,10 +97,45 @@ class NasResourceService
|
||||
}
|
||||
}
|
||||
$model->update($data);
|
||||
if ($hasScripts) $this->syncProfileScripts($model, $scripts);
|
||||
|
||||
return $model->fresh()->load('tenant:id,tenant_code,tenant_name');
|
||||
}
|
||||
|
||||
private function syncProfileScripts(Model $profile, array $scripts): void
|
||||
{
|
||||
$sync=[];
|
||||
foreach($scripts as $row) {
|
||||
$policy = NasScriptPolicy::with('versions:id,nas_script_policy_id')->findOrFail($row['policy_id']);
|
||||
if ($policy->service_type !== $profile->service_type || $policy->event !== $row['event']) {
|
||||
throw ValidationException::withMessages(['scripts' => "Script {$policy->name} tidak sesuai tipe layanan atau event profile."]);
|
||||
}
|
||||
if (($row['version_id'] ?? null) && ! $policy->versions->contains('id', $row['version_id'])) {
|
||||
throw ValidationException::withMessages(['scripts' => "Versi Script {$policy->name} tidak valid."]);
|
||||
}
|
||||
$sync[$row['policy_id']]=['event'=>$row['event'],'nas_script_policy_version_id'=>$row['version_id'] ?? null,'enabled'=>true];
|
||||
}
|
||||
$profile->scripts()->sync($sync);
|
||||
}
|
||||
|
||||
private function normalizePackageProfile(array $data, ?Model $profile = null): array
|
||||
{
|
||||
$type = $data['service_type'] ?? $profile?->service_type;
|
||||
if ($type !== 'hotspot') {
|
||||
foreach (['usage_mode','validity_start','validity_value','validity_unit','uptime_limit_seconds','data_limit_bytes','grace_period_minutes'] as $field) $data[$field] = null;
|
||||
}
|
||||
if ($type !== 'ppp') foreach (['local_address', 'remote_address_pool'] as $field) $data[$field] = null;
|
||||
if ($type !== 'hotspot') foreach (['session_timeout', 'idle_timeout', 'shared_users'] as $field) $data[$field] = null;
|
||||
if (! in_array($type, ['static_ip', 'dynamic_ip'], true)) {
|
||||
foreach (['ip_pool','subnet_cidr','gateway','dns_servers','vlan_id'] as $field) $data[$field] = null;
|
||||
$data['mac_lock_required'] = false;
|
||||
} else {
|
||||
$data['mac_lock_required'] = true;
|
||||
if ($type === 'static_ip') $data['ip_pool'] = null;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function delete(Model $model): bool
|
||||
{
|
||||
return $model->delete();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
namespace App\Services\Nas;
|
||||
|
||||
use App\Models\NasMikrotik;
|
||||
use App\Models\NasPackageProfile;
|
||||
use App\Models\NasPackageProfileTarget;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class PackageProfileDeploymentService
|
||||
{
|
||||
public function __construct(protected RouterOsApiService $routerOs) {}
|
||||
|
||||
public function targets(NasPackageProfile $profile)
|
||||
{
|
||||
return $profile->targets()->with('mikrotik:id,tenant_id,name,connection_type,host,connection_status')->orderBy('id')->get();
|
||||
}
|
||||
|
||||
public function saveTarget(NasPackageProfile $profile, array $data): NasPackageProfileTarget
|
||||
{
|
||||
$nas = NasMikrotik::with('tenant.nasSetting')->whereKey($data['nas_mikrotik_id'])->where('tenant_id', $profile->tenant_id)->firstOrFail();
|
||||
$backendMode = $nas->effectiveBackendMode();
|
||||
return NasPackageProfileTarget::updateOrCreate(
|
||||
['nas_package_profile_id' => $profile->id, 'nas_mikrotik_id' => $nas->id],
|
||||
['tenant_id' => $profile->tenant_id, 'backend_type' => in_array($backendMode, ['platform_radius','external_radius','hybrid'], true) ? 'radius' : 'routeros_api',
|
||||
'external_profile_name' => $data['external_profile_name'], 'sync_status' => 'not_deployed']
|
||||
);
|
||||
}
|
||||
|
||||
public function sync(NasPackageProfileTarget $target, string $mode): NasPackageProfileTarget
|
||||
{
|
||||
$target->load(['packageProfile.scripts.versions', 'mikrotik']);
|
||||
$desired = $this->desiredSnapshot($target->packageProfile);
|
||||
try {
|
||||
$remote = $target->backend_type === 'routeros_api'
|
||||
? (in_array($target->packageProfile->service_type, ['static_ip', 'dynamic_ip'], true)
|
||||
? ['managed_per_customer' => true, 'network' => $desired['network']]
|
||||
: $this->routerOs->syncProfile($target->mikrotik, $target->packageProfile->service_type, $target->external_profile_name, $desired['routeros'], $mode))
|
||||
: ['radius_attributes' => $desired['radius']];
|
||||
$checksum = hash('sha256', json_encode($desired));
|
||||
$target->update(['sync_status' => 'synced', 'desired_snapshot' => $desired, 'remote_snapshot' => $remote,
|
||||
'remote_id' => $remote['.id'] ?? null, 'deployed_checksum' => $checksum, 'last_checked_at' => now(), 'last_synced_at' => now(), 'last_error' => null]);
|
||||
} catch (Throwable $e) {
|
||||
$target->update(['sync_status' => 'error', 'desired_snapshot' => $desired, 'last_checked_at' => now(), 'last_error' => $e instanceof ValidationException ? collect($e->errors())->flatten()->first() : $e->getMessage()]);
|
||||
throw $e;
|
||||
}
|
||||
return $target->fresh('mikrotik');
|
||||
}
|
||||
|
||||
public function assertSynced(NasPackageProfile $profile, NasMikrotik $nas): NasPackageProfileTarget
|
||||
{
|
||||
$target = $profile->targets()->where('nas_mikrotik_id', $nas->id)->where('sync_status', 'synced')->first();
|
||||
if (! $target) throw ValidationException::withMessages(['package_profile_id' => 'Profile paket belum disinkronkan ke NAS yang dipilih.']);
|
||||
return $target;
|
||||
}
|
||||
|
||||
private function desiredSnapshot(NasPackageProfile $profile): array
|
||||
{
|
||||
$scripts = $profile->scripts->keyBy(fn ($policy) => $policy->pivot->event);
|
||||
$script = fn (string $event) => $this->scriptTemplate($scripts->get($event), $profile);
|
||||
$rate = ($profile->upload_kbps ? ($profile->upload_kbps.'k') : '0').'/'.($profile->download_kbps ? ($profile->download_kbps.'k') : '0');
|
||||
$common = ['rate-limit' => $rate, 'session-timeout' => $profile->session_timeout ? $profile->session_timeout.'s' : null,
|
||||
'idle-timeout' => $profile->idle_timeout ? $profile->idle_timeout.'s' : null];
|
||||
$router = $profile->service_type === 'hotspot'
|
||||
? [...$common, 'shared-users' => (string) $profile->shared_users, 'on-login' => $script('on_login'), 'on-logout' => $script('on_logout')]
|
||||
: ($profile->service_type === 'ppp'
|
||||
? [...$common, 'local-address' => $profile->local_address, 'remote-address' => $profile->remote_address_pool, 'on-up' => $script('on_up'), 'on-down' => $script('on_down')]
|
||||
: []);
|
||||
return ['routeros' => $router, 'radius' => ['Mikrotik-Rate-Limit' => $rate, 'Session-Timeout' => $profile->session_timeout, 'Idle-Timeout' => $profile->idle_timeout, 'Simultaneous-Use' => $profile->shared_users],
|
||||
'network' => $profile->only(['mac_lock_required','ip_pool','subnet_cidr','gateway','dns_servers','vlan_id']),
|
||||
'profile' => $profile->only(['profile_code','service_type','usage_mode','validity_start','validity_value','validity_unit','uptime_limit_seconds','data_limit_bytes'])];
|
||||
}
|
||||
|
||||
private function scriptTemplate($policy, NasPackageProfile $profile): ?string
|
||||
{
|
||||
if (! $policy || ! $policy->pivot->enabled) return null;
|
||||
|
||||
$versionId = $policy->pivot->nas_script_policy_version_id;
|
||||
$version = $versionId
|
||||
? $policy->versions->firstWhere('id', $versionId)
|
||||
: $policy->versions->sortByDesc('version')->first();
|
||||
if (! $version) return null;
|
||||
|
||||
return strtr($version->script_template, [
|
||||
'{{ username }}' => '$user',
|
||||
'{{username}}' => '$user',
|
||||
'{{ package_code }}' => (string) $profile->profile_code,
|
||||
'{{package_code}}' => (string) $profile->profile_code,
|
||||
'{{ tenant_code }}' => (string) optional($profile->tenant)->tenant_code,
|
||||
'{{tenant_code}}' => (string) optional($profile->tenant)->tenant_code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Nas;
|
||||
|
||||
use App\Models\NasMikrotik;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class RadiusHealthService
|
||||
{
|
||||
public function testConnection(NasMikrotik $radius): array
|
||||
{
|
||||
if (! $radius->host || ! $radius->radius_secret) {
|
||||
throw ValidationException::withMessages(['radius' => 'Host dan shared secret RADIUS wajib lengkap.']);
|
||||
}
|
||||
|
||||
$socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
|
||||
if ($socket === false) {
|
||||
throw ValidationException::withMessages(['radius' => 'Socket UDP untuk RADIUS tidak dapat dibuat.']);
|
||||
}
|
||||
$timeout = max(1, min((int) ($radius->timeout ?: 5), 10));
|
||||
socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => $timeout, 'usec' => 0]);
|
||||
$identifier = random_int(0, 255);
|
||||
$requestAuthenticator = random_bytes(16);
|
||||
$request = pack('CCn', 12, $identifier, 20).$requestAuthenticator;
|
||||
$startedAt = hrtime(true);
|
||||
|
||||
try {
|
||||
$sent = @socket_sendto(
|
||||
$socket,
|
||||
$request,
|
||||
strlen($request),
|
||||
0,
|
||||
$radius->host,
|
||||
(int) ($radius->radius_auth_port ?: 1812),
|
||||
);
|
||||
if ($sent === false) {
|
||||
throw ValidationException::withMessages(['radius' => 'Status-Server RADIUS gagal dikirim.']);
|
||||
}
|
||||
$response = '';
|
||||
$from = '';
|
||||
$port = 0;
|
||||
$received = @socket_recvfrom($socket, $response, 4096, 0, $from, $port);
|
||||
if ($received === false || $received < 20) {
|
||||
throw ValidationException::withMessages([
|
||||
'radius' => 'RADIUS tidak merespons Status-Server. Pastikan Status-Server diizinkan oleh server.',
|
||||
]);
|
||||
}
|
||||
|
||||
['code' => $code, 'identifier' => $responseIdentifier, 'length' => $length] = unpack('Ccode/Cidentifier/nlength', substr($response, 0, 4));
|
||||
if ($responseIdentifier !== $identifier || $length > strlen($response)) {
|
||||
throw ValidationException::withMessages(['radius' => 'Respons RADIUS tidak valid.']);
|
||||
}
|
||||
$attributes = substr($response, 20, $length - 20);
|
||||
$expected = md5(substr($response, 0, 4).$requestAuthenticator.$attributes.$radius->radius_secret, true);
|
||||
if (! hash_equals($expected, substr($response, 4, 16))) {
|
||||
throw ValidationException::withMessages(['radius' => 'Authenticator respons RADIUS tidak valid.']);
|
||||
}
|
||||
|
||||
return [
|
||||
'connected' => true,
|
||||
'latency_ms' => (int) round((hrtime(true) - $startedAt) / 1_000_000),
|
||||
'host' => $radius->host,
|
||||
'auth_port' => (int) ($radius->radius_auth_port ?: 1812),
|
||||
'accounting_port' => (int) ($radius->radius_accounting_port ?: 1813),
|
||||
'response_code' => $code,
|
||||
'probe' => 'radius_status_server',
|
||||
];
|
||||
} finally {
|
||||
socket_close($socket);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Nas;
|
||||
|
||||
use App\Models\NasMikrotik;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use RouterOS\Client;
|
||||
use RouterOS\Query;
|
||||
use Throwable;
|
||||
|
||||
class RouterOsApiService
|
||||
{
|
||||
public function testConnection(NasMikrotik $mikrotik): array
|
||||
{
|
||||
$startedAt = hrtime(true);
|
||||
$client = $this->connect($mikrotik);
|
||||
$identity = $client->query('/system/identity/print')->read()[0] ?? [];
|
||||
$resource = $client->query('/system/resource/print')->read()[0] ?? [];
|
||||
|
||||
return [
|
||||
'connected' => true,
|
||||
'latency_ms' => (int) round((hrtime(true) - $startedAt) / 1_000_000),
|
||||
'identity' => $identity['name'] ?? null,
|
||||
'version' => $resource['version'] ?? null,
|
||||
'uptime' => $resource['uptime'] ?? null,
|
||||
'platform' => $resource['platform'] ?? null,
|
||||
'board_name' => $resource['board-name'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
public function createAccount(NasMikrotik $mikrotik, string $serviceType, array $attributes): array
|
||||
{
|
||||
$client = $this->connect($mikrotik);
|
||||
$this->assertUsernameAvailable($client, $serviceType, $attributes['username']);
|
||||
$query = new Query($this->path($serviceType, 'add'));
|
||||
foreach ($this->accountAttributes($attributes) as $key => $value) {
|
||||
$query->equal($key, $value);
|
||||
}
|
||||
$client->query($query)->read();
|
||||
|
||||
return $this->findAccount($client, $serviceType, $attributes['username']);
|
||||
}
|
||||
|
||||
public function updateAccount(NasMikrotik $mikrotik, string $serviceType, string $username, array $attributes): array
|
||||
{
|
||||
$client = $this->connect($mikrotik);
|
||||
$account = $this->findAccount($client, $serviceType, $username);
|
||||
$query = (new Query($this->path($serviceType, 'set')))->equal('.id', $account['.id']);
|
||||
foreach ($this->accountAttributes($attributes) as $key => $value) {
|
||||
$query->equal($key, $value);
|
||||
}
|
||||
$client->query($query)->read();
|
||||
|
||||
return $this->findAccount($client, $serviceType, $attributes['username'] ?? $username);
|
||||
}
|
||||
|
||||
public function setEnabled(NasMikrotik $mikrotik, string $serviceType, string $username, bool $enabled): array
|
||||
{
|
||||
return $this->updateAccount($mikrotik, $serviceType, $username, [
|
||||
'disabled' => $enabled ? 'no' : 'yes',
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteAccount(NasMikrotik $mikrotik, string $serviceType, string $username): void
|
||||
{
|
||||
$client = $this->connect($mikrotik);
|
||||
$account = $this->findAccount($client, $serviceType, $username);
|
||||
$client->query(
|
||||
(new Query($this->path($serviceType, 'remove')))->equal('.id', $account['.id'])
|
||||
)->read();
|
||||
}
|
||||
|
||||
public function syncProfile(NasMikrotik $mikrotik, string $serviceType, string $name, array $attributes, string $mode = 'update'): array
|
||||
{
|
||||
$client = $this->connect($mikrotik);
|
||||
$base = $serviceType === 'hotspot' ? '/ip/hotspot/user/profile' : '/ppp/profile';
|
||||
$rows = $client->query((new Query("{$base}/print"))->where('name', $name))->read();
|
||||
$existing = $rows[0] ?? null;
|
||||
if ($mode === 'adopt') {
|
||||
if (! $existing) throw ValidationException::withMessages(['profile' => "Profile {$name} tidak ditemukan untuk di-adopt."]);
|
||||
return $existing;
|
||||
}
|
||||
if (! $existing) {
|
||||
$query = (new Query("{$base}/add"))->equal('name', $name);
|
||||
} else {
|
||||
$query = (new Query("{$base}/set"))->equal('.id', $existing['.id']);
|
||||
}
|
||||
foreach (array_filter($attributes, fn ($value) => $value !== null) as $key => $value) $query->equal($key, $value);
|
||||
$client->query($query)->read();
|
||||
$rows = $client->query((new Query("{$base}/print"))->where('name', $name))->read();
|
||||
return $rows[0] ?? [];
|
||||
}
|
||||
|
||||
public function inspectAccount(NasMikrotik $mikrotik, string $serviceType, string $username): array
|
||||
{
|
||||
$client = $this->connect($mikrotik);
|
||||
$account = $this->findAccount($client, $serviceType, $username);
|
||||
$activeBase = in_array($serviceType, ['ppp','pppoe'], true) ? '/ppp/active' : '/ip/hotspot/active';
|
||||
$active = $client->query((new Query("{$activeBase}/print"))->where('name', $username))->read()[0] ?? null;
|
||||
return ['account' => $account, 'active' => $active];
|
||||
}
|
||||
|
||||
public function disconnectAccount(NasMikrotik $mikrotik, string $serviceType, string $username): void
|
||||
{
|
||||
$client = $this->connect($mikrotik);
|
||||
$activeBase = in_array($serviceType, ['ppp','pppoe'], true) ? '/ppp/active' : '/ip/hotspot/active';
|
||||
$rows = $client->query((new Query("{$activeBase}/print"))->where('name', $username))->read();
|
||||
foreach ($rows as $row) if (! empty($row['.id'])) $client->query((new Query("{$activeBase}/remove"))->equal('.id', $row['.id']))->read();
|
||||
}
|
||||
|
||||
private function connect(NasMikrotik $mikrotik): Client
|
||||
{
|
||||
if ($mikrotik->connection_type !== 'api') {
|
||||
throw ValidationException::withMessages(['mikrotik' => 'Perangkat tidak menggunakan koneksi RouterOS API.']);
|
||||
}
|
||||
if ($mikrotik->status !== 'active') {
|
||||
throw ValidationException::withMessages(['mikrotik' => 'Perangkat Mikrotik sedang nonaktif.']);
|
||||
}
|
||||
if (! $mikrotik->host || ! $mikrotik->api_username || ! $mikrotik->api_password) {
|
||||
throw ValidationException::withMessages(['mikrotik' => 'Host, username API, dan password API wajib lengkap.']);
|
||||
}
|
||||
|
||||
try {
|
||||
return new Client([
|
||||
'host' => $mikrotik->host,
|
||||
'user' => $mikrotik->api_username,
|
||||
'pass' => $mikrotik->api_password,
|
||||
'port' => (int) ($mikrotik->api_port ?: 8728),
|
||||
'timeout' => (int) ($mikrotik->timeout ?: 10),
|
||||
'socket_timeout' => (int) ($mikrotik->timeout ?: 10),
|
||||
'attempts' => 1,
|
||||
'delay' => 0,
|
||||
'ssl' => (int) ($mikrotik->api_port ?: 8728) === 8729,
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'mikrotik' => 'Koneksi RouterOS API gagal: '.$exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function findAccount(Client $client, string $serviceType, string $username): array
|
||||
{
|
||||
$rows = $client->query(
|
||||
(new Query($this->path($serviceType, 'print')))->where('name', $username)
|
||||
)->read();
|
||||
if (empty($rows[0]['.id'])) {
|
||||
throw ValidationException::withMessages(['username' => "Akun {$username} tidak ditemukan di Mikrotik."]);
|
||||
}
|
||||
|
||||
return $rows[0];
|
||||
}
|
||||
|
||||
private function assertUsernameAvailable(Client $client, string $serviceType, string $username): void
|
||||
{
|
||||
$rows = $client->query(
|
||||
(new Query($this->path($serviceType, 'print')))->where('name', $username)
|
||||
)->read();
|
||||
if ($rows) {
|
||||
throw ValidationException::withMessages(['username' => "Username {$username} sudah ada di Mikrotik."]);
|
||||
}
|
||||
}
|
||||
|
||||
private function accountAttributes(array $attributes): array
|
||||
{
|
||||
return array_filter([
|
||||
'name' => $attributes['username'] ?? null,
|
||||
'password' => $attributes['password'] ?? null,
|
||||
'profile' => $attributes['profile'] ?? null,
|
||||
'comment' => $attributes['comment'] ?? null,
|
||||
'disabled' => $attributes['disabled'] ?? null,
|
||||
], fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
private function path(string $serviceType, string $action): string
|
||||
{
|
||||
$base = match ($serviceType) {
|
||||
'ppp', 'pppoe' => '/ppp/secret',
|
||||
'hotspot' => '/ip/hotspot/user',
|
||||
default => throw ValidationException::withMessages(['service_type' => 'Tipe layanan harus PPPoE atau Hotspot.']),
|
||||
};
|
||||
|
||||
return "{$base}/{$action}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
namespace App\Services\Nas;
|
||||
use App\Models\NasScriptPolicy;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ScriptPolicyService
|
||||
{
|
||||
private const VARIABLES = ['username','customer_code','customer_name','package_code','tenant_code','valid_until'];
|
||||
public function list(array $filters) { return NasScriptPolicy::with('latestVersion')->when($filters['search'] ?? null, fn($q,$s)=>$q->where('name','ILIKE',"%{$s}%")->orWhere('code','ILIKE',"%{$s}%"))->orderBy('service_type')->orderBy('event')->paginate($filters['per_page'] ?? 10); }
|
||||
public function save(array $data, int $userId, ?NasScriptPolicy $policy = null): NasScriptPolicy
|
||||
{
|
||||
$allowedEvents = $data['service_type'] === 'ppp' ? ['on_up', 'on_down'] : ['on_login', 'on_logout'];
|
||||
if (! in_array($data['event'], $allowedEvents, true)) {
|
||||
throw ValidationException::withMessages(['event' => 'Event tidak sesuai dengan tipe layanan Script Policy.']);
|
||||
}
|
||||
preg_match_all('/\{\{\s*([a-z_]+)\s*\}\}/', $data['script_template'], $matches);
|
||||
$invalid = array_diff(array_unique($matches[1]), self::VARIABLES);
|
||||
if ($invalid) throw ValidationException::withMessages(['script_template' => 'Variabel tidak diizinkan: '.implode(', ', $invalid)]);
|
||||
return DB::transaction(function () use ($data,$userId,$policy) {
|
||||
$version = $policy ? $policy->current_version + 1 : 1;
|
||||
$values = collect($data)->except(['script_template','change_notes'])->all();
|
||||
$values['allowed_variables'] = self::VARIABLES; $values['current_version'] = $version; $values['created_by'] ??= $userId;
|
||||
$policy ? $policy->update($values) : $policy = NasScriptPolicy::create($values);
|
||||
$policy->versions()->create(['version'=>$version,'script_template'=>$data['script_template'],'checksum'=>hash('sha256',$data['script_template']),'change_notes'=>$data['change_notes'] ?? null,'created_by'=>$userId]);
|
||||
return $policy->fresh(['latestVersion','versions']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,34 +4,45 @@ namespace App\Services\Ticket;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketLog;
|
||||
use App\Models\TicketType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TicketService
|
||||
{
|
||||
public function create(array $data): Ticket
|
||||
public function create(array $data, User $actor, ?int $tenantId): Ticket
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
return DB::transaction(function () use ($data, $actor, $tenantId) {
|
||||
|
||||
$tenantId = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
|
||||
abort_unless($tenantId, 422, 'Tenant wajib dipilih.');
|
||||
if (! empty($data['customer_id'])) abort_unless(\App\Models\Customer::whereKey($data['customer_id'])->where('tenant_id', $tenantId)->exists(), 422, 'Customer bukan milik tenant aktif.');
|
||||
$type = TicketType::findOrFail($data['ticket_type_id']);
|
||||
|
||||
$ticket = Ticket::create([
|
||||
'ticket_no' => $this->generateTicketNo(),
|
||||
'ticket_type_id' => $data['ticket_type_id'],
|
||||
'customer_id' => $data['customer_id'] ?? null,
|
||||
'tenant_id' => $tenantId,
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'priority' => $data['priority'],
|
||||
'status' => 'open',
|
||||
'created_by' => 1
|
||||
'status' => $type->need_approval ? 'open' : 'approved',
|
||||
'sla_minutes' => $type->sla_minutes,
|
||||
'created_by' => $actor->id
|
||||
,'approved_by' => $type->need_approval ? null : $actor->id
|
||||
,'approved_at' => $type->need_approval ? null : now()
|
||||
]);
|
||||
|
||||
TicketLog::create([
|
||||
'ticket_id' => $ticket->id,
|
||||
'user_id' => 1,
|
||||
'user_id' => $actor->id,
|
||||
'activity' => 'Ticket Created',
|
||||
'notes' => 'Ticket dibuat oleh sistem',
|
||||
'notes' => 'Ticket dibuat oleh '.$actor->name,
|
||||
'created_at'=> now()
|
||||
]);
|
||||
|
||||
return $ticket;
|
||||
return $ticket->load(['ticketType','customer','tenant','creator','assignments.user']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,4 +87,6 @@ class TicketService
|
||||
STR_PAD_LEFT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function generateNo(): string { return $this->generateTicketNo(); }
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class TicketWorkflowService
|
||||
|
||||
// PHOTO VALIDATION
|
||||
if ($type?->require_photo) {
|
||||
$photoCount = $payload['photo_count'] ?? 0;
|
||||
$photoCount = $payload['photo_count'] ?? ($ticket->customer?->images()->count() ?? 0);
|
||||
|
||||
if ($photoCount <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -50,7 +50,7 @@ class TicketWorkflowService
|
||||
|
||||
// MATERIAL VALIDATION
|
||||
if ($type?->require_material) {
|
||||
$materialCount = $payload['material_count'] ?? 0;
|
||||
$materialCount = $payload['material_count'] ?? \App\Models\TicketMaterial::where('ticket_id', $ticket->id)->count();
|
||||
|
||||
if ($materialCount <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -85,6 +85,8 @@ class TicketWorkflowService
|
||||
): Ticket {
|
||||
|
||||
TicketWorkflowValidator::allow($ticket, ['approved']);
|
||||
$validCount=\App\Models\UserTenant::where('tenant_id',$ticket->tenant_id)->whereIn('user_id',$technicians)->distinct('user_id')->count('user_id');
|
||||
if($validCount!==count(array_unique($technicians)))throw ValidationException::withMessages(['technicians'=>'Semua teknisi harus terdaftar pada tenant ticket.']);
|
||||
|
||||
DB::transaction(function () use (
|
||||
$ticket,
|
||||
@@ -219,8 +221,10 @@ class TicketWorkflowService
|
||||
public function start(Ticket $ticket, array $location, int $userId): Ticket
|
||||
{
|
||||
TicketWorkflowValidator::allow($ticket, ['assigned']);
|
||||
$this->ensureAssignedActor($ticket,$userId);
|
||||
|
||||
DB::transaction(function () use ($ticket, $location, $userId) {
|
||||
$this->attachFiles($ticket,$location['stored_file_ids'],'start',$userId,$location['notes']??null);
|
||||
|
||||
$ticket->update([
|
||||
'status' => 'progress'
|
||||
@@ -237,17 +241,17 @@ class TicketWorkflowService
|
||||
return $ticket->fresh();
|
||||
}
|
||||
|
||||
public function pending(Ticket $ticket, string $reason, int $userId): Ticket
|
||||
public function pending(Ticket $ticket, string $reason, int $userId, array $fileIds=[]): Ticket
|
||||
{
|
||||
$ticket->update(['status' => 'pending']);
|
||||
|
||||
$this->log($ticket->id, $userId, 'Pending', $reason);
|
||||
TicketWorkflowValidator::allow($ticket, ['progress']);$this->ensureAssignedActor($ticket,$userId);
|
||||
DB::transaction(function()use($ticket,$reason,$userId,$fileIds){$this->attachFiles($ticket,$fileIds,'pending',$userId,$reason);$ticket->update(['status'=>'pending']);$this->log($ticket->id,$userId,'Pending',$reason);});
|
||||
|
||||
return $ticket->fresh();
|
||||
}
|
||||
|
||||
public function resume(Ticket $ticket, int $userId): Ticket
|
||||
{
|
||||
TicketWorkflowValidator::allow($ticket, ['pending']);$this->ensureAssignedActor($ticket,$userId);
|
||||
$ticket->update(['status' => 'progress']);
|
||||
|
||||
$this->log($ticket->id, $userId, 'Resume', 'Dilanjutkan');
|
||||
@@ -269,12 +273,15 @@ class TicketWorkflowService
|
||||
): Ticket {
|
||||
|
||||
TicketWorkflowValidator::allow($ticket, ['progress']);
|
||||
$this->ensureAssignedActor($ticket,$userId);
|
||||
if ($ticket->source === 'new_installation' && $ticket->installation_step !== 'completed') throw ValidationException::withMessages(['installation_step'=>'Seluruh proses pemasangan dan aktivasi harus diselesaikan terlebih dahulu.']);
|
||||
|
||||
// 🔥 VALIDASI MATERIAL + PHOTO + CUSTOMER
|
||||
$this->ensureRequiredAssets($ticket, $payload);
|
||||
$this->ensureCustomer($ticket);
|
||||
|
||||
DB::transaction(function () use ($ticket, $notes, $userId) {
|
||||
DB::transaction(function () use ($ticket, $notes, $userId, $payload) {
|
||||
$this->attachFiles($ticket,$payload['stored_file_ids']??[],'resolve',$userId,$notes);
|
||||
|
||||
$ticket->update([
|
||||
'status' => 'resolved',
|
||||
@@ -290,6 +297,7 @@ class TicketWorkflowService
|
||||
public function close(Ticket $ticket, ?string $notes, int $userId): Ticket
|
||||
{
|
||||
TicketWorkflowValidator::allow($ticket, ['resolved']);
|
||||
if (! $ticket->verified_at) throw ValidationException::withMessages(['verification'=>'Ticket harus diverifikasi sebelum ditutup.']);
|
||||
|
||||
$ticket->update([
|
||||
'status' => 'closed',
|
||||
@@ -338,4 +346,15 @@ class TicketWorkflowService
|
||||
'created_at'=> now()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureAssignedActor(Ticket $ticket,int $userId):void
|
||||
{
|
||||
$user=\App\Models\User::findOrFail($userId);
|
||||
if($user->isMasterAdmin()||$user->isTenantOwner($ticket->tenant_id))return;
|
||||
abort_unless(TicketAssignment::where('ticket_id',$ticket->id)->where('user_id',$userId)->whereIn('status',['assigned','accepted'])->exists(),403,'Pekerjaan hanya dapat diproses teknisi yang ditugaskan.');
|
||||
}
|
||||
private function attachFiles(Ticket $ticket,array $ids,string $stage,int $userId,?string $notes=null):void
|
||||
{
|
||||
foreach(array_unique($ids) as $id){$file=\App\Models\StoredFile::findOrFail($id);abort_unless((int)$file->tenant_id===(int)$ticket->tenant_id,422,'File bukan milik tenant ticket.');\App\Models\TicketAttachment::firstOrCreate(['ticket_id'=>$ticket->id,'stored_file_id'=>$id,'stage'=>$stage],['uploaded_by'=>$userId,'notes'=>$notes]);}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Services\Ticket;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use Exception;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TicketWorkflowValidator
|
||||
{
|
||||
@@ -14,9 +14,7 @@ class TicketWorkflowValidator
|
||||
|
||||
if (! in_array($ticket->status, $allowedStatuses)) {
|
||||
|
||||
throw new Exception(
|
||||
"Ticket status '{$ticket->status}' tidak valid untuk proses ini"
|
||||
);
|
||||
throw ValidationException::withMessages(['status' => "Ticket berstatus '{$ticket->status}' tidak dapat menjalankan proses ini."]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user