Giant Update

This commit is contained in:
Wian Drs
2026-07-31 13:57:11 +07:00
parent f68d967c8b
commit b0d08211ec
112 changed files with 6674 additions and 3 deletions
+283
View File
@@ -0,0 +1,283 @@
<?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\Services\Notification\NotificationManager;
use Illuminate\Validation\ValidationException;
class CustomerService
{
public function __construct(protected NotificationManager $notifications) {}
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',
];
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.']);
}
$data['customer_code'] = filled($data['customer_code'] ?? null)
? $data['customer_code']
: $this->nextCode((int) $data['tenant_id']);
$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->load('tenant:id,tenant_name');
$this->notifications->emit(
'customer.registration',
$customer,
$customer,
['customer' => $customer->toArray(), 'tenant' => $customer->tenant?->toArray() ?? []],
'registration',
['action_url' => "/customers/orders/{$customer->id}/detail"],
);
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']);
$customer->update($data);
$this->syncPrimaryContacts($customer);
if ($hasImages) {
$this->syncImages($customer, $images, $actor);
}
return $customer->fresh()->load(self::DETAIL_RELATIONS);
}
public function activate(Customer $customer, array $data): 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.',
]);
}
$customer->update([
...$data,
'status' => 'active',
'order_stage' => 'ready_activation',
'activated_at' => now(),
'inactivated_at' => null,
]);
$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
{
$customer->update(['status' => 'inactive', 'inactivated_at' => now()]);
return $customer->fresh()->load(self::DETAIL_RELATIONS);
}
public function trash(Customer $customer): void
{
$customer->delete();
}
public function restore(Customer $customer): Customer
{
$customer->restore();
return $customer->fresh()->load(self::DETAIL_RELATIONS);
}
public function forceDelete(Customer $customer): void
{
$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']),
];
}
private function nextCode(int $tenantId): string
{
$next = ((int) Customer::withTrashed()->where('tenant_id', $tenantId)->max('id')) + 1;
return 'CUST-'.str_pad((string) $next, 6, '0', STR_PAD_LEFT);
}
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.']);
}
}
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,
],
);
}
}
}