410 lines
21 KiB
PHP
410 lines
21 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Customer;
|
|
|
|
use App\Models\BillingProfile;
|
|
use App\Models\Customer;
|
|
use App\Models\CustomerContact;
|
|
use App\Models\NasMikrotik;
|
|
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,
|
|
protected CustomerRouterOsProvisioningService $routerOsProvisioning,
|
|
protected \App\Services\Ticket\TicketService $ticketService,
|
|
) {}
|
|
|
|
private const BASE_RELATIONS = [
|
|
'tenant:id,tenant_code,tenant_name',
|
|
'provinsi:id,kode,nama',
|
|
'kabupaten:id,provinsi_id,kode,nama',
|
|
'kecamatan:id,kabupaten_id,kode,nama',
|
|
'desa:id,kecamatan_id,kode,nama',
|
|
'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 = [
|
|
...self::BASE_RELATIONS,
|
|
'images.file:id,uuid,tenant_id,uploaded_by,original_name,mime_type,size,category',
|
|
];
|
|
|
|
private const LIST_RELATIONS = [
|
|
...self::BASE_RELATIONS,
|
|
'coverImage.file:id,uuid,original_name,mime_type,size',
|
|
];
|
|
|
|
public function list(string $scope, array $filters, User $actor, ?int $tenantId)
|
|
{
|
|
$query = Customer::query()->with(self::LIST_RELATIONS);
|
|
if ($scope === 'trash') {
|
|
$query->onlyTrashed();
|
|
} else {
|
|
$query->where('status', $scope === 'orders' ? 'order' : $scope);
|
|
}
|
|
|
|
return $query
|
|
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
|
|
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
|
->when(! empty($filters['source']), fn ($q) => $q->where('source', $filters['source']))
|
|
->when(! empty($filters['search']), fn ($q) => $q->where(function ($search) use ($filters) {
|
|
$term = "%{$filters['search']}%";
|
|
$search->where('customer_code', 'ILIKE', $term)
|
|
->orWhere('name', 'ILIKE', $term)
|
|
->orWhere('email', 'ILIKE', $term)
|
|
->orWhere('whatsapp_number', 'ILIKE', $term)
|
|
->orWhere('external_username', 'ILIKE', $term);
|
|
}))
|
|
->orderBy($filters['sort_by'] ?? 'id', $filters['sort_direction'] ?? 'desc')
|
|
->paginate($filters['per_page'] ?? 10);
|
|
}
|
|
|
|
public function find(int $id, bool $trashed = false): Customer
|
|
{
|
|
return Customer::query()
|
|
->when($trashed, fn ($q) => $q->withTrashed())
|
|
->with(self::DETAIL_RELATIONS)
|
|
->findOrFail($id);
|
|
}
|
|
|
|
public function create(array $data, User $actor, ?int $tenantId): Customer
|
|
{
|
|
$images = $data['images'] ?? [];
|
|
unset($data['images']);
|
|
$data['tenant_id'] = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
|
|
if (! $data['tenant_id']) {
|
|
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
|
}
|
|
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 = 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,
|
|
$customer,
|
|
['customer' => $customer->toArray(), 'tenant' => $customer->tenant?->toArray() ?? []],
|
|
'registration',
|
|
['action_url' => "/customers/orders/{$customer->id}/detail"],
|
|
);
|
|
User::query()
|
|
->whereHas('userTenants', fn ($query) => $query
|
|
->where('tenant_id', $customer->tenant_id)
|
|
->where('role', 'tenant_owner'))
|
|
->each(fn (User $owner) => $this->notifications->emit(
|
|
'customer.registration',
|
|
$owner,
|
|
$customer,
|
|
['customer' => $customer->toArray(), 'tenant' => $customer->tenant?->toArray() ?? []],
|
|
'registration',
|
|
['action_url' => "/customers/orders/{$customer->id}/detail", 'icon' => 'cil-user-follow'],
|
|
));
|
|
|
|
return $customer->load(self::DETAIL_RELATIONS);
|
|
}
|
|
|
|
public function update(Customer $customer, array $data, User $actor): Customer
|
|
{
|
|
$hasImages = array_key_exists('images', $data);
|
|
$images = $data['images'] ?? [];
|
|
unset($data['images']);
|
|
if (! $actor->isMasterAdmin()) {
|
|
unset($data['tenant_id']);
|
|
}
|
|
$data['tenant_id'] ??= $customer->tenant_id;
|
|
$this->validateTenantRelations($data);
|
|
$this->validateWilayah($data);
|
|
unset($data['status']);
|
|
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);
|
|
}
|
|
|
|
return $customer->fresh()->load(self::DETAIL_RELATIONS);
|
|
}
|
|
|
|
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) {
|
|
throw ValidationException::withMessages([
|
|
'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',
|
|
$customer,
|
|
$customer,
|
|
['customer' => $customer->toArray(), 'tenant' => $customer->tenant?->toArray() ?? []],
|
|
'activation-'.$customer->activated_at->format('YmdHis'),
|
|
['action_url' => "/customers/active/{$customer->id}/detail"],
|
|
);
|
|
|
|
return $customer->fresh()->load(self::DETAIL_RELATIONS);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
public function trash(Customer $customer): void
|
|
{
|
|
$this->releaseTopologyPort($customer);
|
|
$customer->delete();
|
|
}
|
|
|
|
public function restore(Customer $customer): Customer
|
|
{
|
|
$customer->restore();
|
|
|
|
return $customer->fresh()->load(self::DETAIL_RELATIONS);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
public function options(User $actor, ?int $tenantId): array
|
|
{
|
|
$scope = fn ($query) => $query
|
|
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
|
|
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
|
->where('status', 'active')
|
|
->orderBy('name');
|
|
|
|
return [
|
|
'package_profiles' => $scope(NasPackageProfile::query())
|
|
->get(['id', 'tenant_id', 'name', 'service_type', 'download_kbps', 'upload_kbps', 'price']),
|
|
'nas_mikrotiks' => $scope(NasMikrotik::query())
|
|
->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,
|
|
];
|
|
}
|
|
|
|
public function availableOdpPorts(int $nodeId, User $actor, ?int $tenantId, ?int $customerId=null)
|
|
{
|
|
$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();
|
|
}
|
|
|
|
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
|
|
{
|
|
$tenantId = (int) $data['tenant_id'];
|
|
if (! empty($data['package_profile_id']) && ! NasPackageProfile::whereKey($data['package_profile_id'])->where('tenant_id', $tenantId)->exists()) {
|
|
throw ValidationException::withMessages(['package_profile_id' => 'Profile paket bukan milik tenant customer.']);
|
|
}
|
|
if (! empty($data['nas_mikrotik_id']) && ! NasMikrotik::whereKey($data['nas_mikrotik_id'])->where('tenant_id', $tenantId)->exists()) {
|
|
throw ValidationException::withMessages(['nas_mikrotik_id' => 'NAS Mikrotik bukan milik tenant customer.']);
|
|
}
|
|
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
|
|
{
|
|
if (empty($data['desa_id'])) {
|
|
return;
|
|
}
|
|
$desa = WilayahDesa::with('kecamatan.kabupaten')->find($data['desa_id']);
|
|
if (! $desa
|
|
|| (! empty($data['kecamatan_id']) && (int) $desa->kecamatan_id !== (int) $data['kecamatan_id'])
|
|
|| (! empty($data['kabupaten_id']) && (int) $desa->kecamatan->kabupaten_id !== (int) $data['kabupaten_id'])
|
|
|| (! empty($data['provinsi_id']) && (int) $desa->kecamatan->kabupaten->provinsi_id !== (int) $data['provinsi_id'])) {
|
|
throw ValidationException::withMessages(['desa_id' => 'Hierarki wilayah pemasangan tidak sesuai.']);
|
|
}
|
|
}
|
|
|
|
private function syncImages(Customer $customer, array $images, User $actor): void
|
|
{
|
|
$coverAssigned = false;
|
|
$rows = collect($images)->map(function (array $image, int $index) use ($customer, $actor, &$coverAssigned) {
|
|
$file = StoredFile::findOrFail($image['stored_file_id']);
|
|
$allowed = $actor->isMasterAdmin()
|
|
|| $file->uploaded_by === $actor->id
|
|
|| ((int) $file->tenant_id === (int) $customer->tenant_id);
|
|
if (! $allowed || $file->category !== 'customer-image' || ! str_starts_with((string) $file->mime_type, 'image/')) {
|
|
throw ValidationException::withMessages(['images' => 'File galeri customer tidak valid atau tidak dapat diakses.']);
|
|
}
|
|
$isCover = ! $coverAssigned && (bool) ($image['is_cover'] ?? $index === 0);
|
|
$coverAssigned = $coverAssigned || $isCover;
|
|
|
|
return [
|
|
'stored_file_id' => $file->id,
|
|
'category' => $image['category'],
|
|
'caption' => $image['caption'] ?? null,
|
|
'is_cover' => $isCover,
|
|
'sort_order' => $image['sort_order'] ?? $index,
|
|
];
|
|
})->all();
|
|
|
|
$customer->images()->delete();
|
|
if ($rows) {
|
|
$customer->images()->createMany($rows);
|
|
}
|
|
}
|
|
|
|
private function syncPrimaryContacts(Customer $customer): void
|
|
{
|
|
foreach (['email' => $customer->email, 'whatsapp' => $customer->whatsapp_number] as $type => $value) {
|
|
if (! $value) {
|
|
continue;
|
|
}
|
|
CustomerContact::where('customer_id', $customer->id)->where('type', $type)->where('value', '!=', $value)
|
|
->update(['is_primary' => false]);
|
|
CustomerContact::updateOrCreate(
|
|
['customer_id' => $customer->id, 'type' => $type, 'value' => $value],
|
|
[
|
|
'tenant_id' => $customer->tenant_id,
|
|
'label' => 'Kontak utama',
|
|
'is_primary' => true,
|
|
'can_receive_transactional' => true,
|
|
'can_receive_broadcast' => true,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
}
|