feat: phase 5 — complaints with ownership-guarded resolution and activity logging
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\InsForge\InsForgeClient;
|
||||
use App\Services\InsForge\InsForgeTableService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ComplaintService extends InsForgeTableService
|
||||
{
|
||||
public const STATUS_OPEN = 'open';
|
||||
|
||||
public const STATUS_RESOLVED = 'resolved';
|
||||
|
||||
public const CATEGORIES = [
|
||||
'product' => 'Produk',
|
||||
'delivery' => 'Pengiriman',
|
||||
'service' => 'Layanan',
|
||||
'other' => 'Lainnya',
|
||||
];
|
||||
|
||||
public const PRIORITIES = [
|
||||
'low' => 'Rendah',
|
||||
'medium' => 'Sedang',
|
||||
'high' => 'Tinggi',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
InsForgeClient $client,
|
||||
private readonly CustomerService $customers,
|
||||
private readonly ActivityLogger $activity,
|
||||
) {
|
||||
parent::__construct($client);
|
||||
}
|
||||
|
||||
protected function table(): string
|
||||
{
|
||||
return 'complaints';
|
||||
}
|
||||
|
||||
protected function select(): string
|
||||
{
|
||||
return '*,customers(code,name),orders(order_number)';
|
||||
}
|
||||
|
||||
protected function searchableColumns(): array
|
||||
{
|
||||
return ['complaint_number', 'description'];
|
||||
}
|
||||
|
||||
protected function sortableColumns(): array
|
||||
{
|
||||
return ['complaint_number', 'complaint_date', 'status', 'priority', 'created_at'];
|
||||
}
|
||||
|
||||
public function createComplaint(array $attributes, ?User $actor = null): array
|
||||
{
|
||||
[$client, $asUser] = $this->scoped();
|
||||
|
||||
$number = $this->nextComplaintNumber();
|
||||
|
||||
$row = array_filter([
|
||||
'complaint_number' => $number,
|
||||
'customer_id' => $attributes['customer_id'] ?? null,
|
||||
'order_id' => $attributes['order_id'] ?? null,
|
||||
'complaint_date' => $attributes['complaint_date'] ?? now()->toDateString(),
|
||||
'category' => $attributes['category'] ?? 'other',
|
||||
'description' => $attributes['description'] ?? null,
|
||||
'priority' => $attributes['priority'] ?? 'medium',
|
||||
'status' => self::STATUS_OPEN,
|
||||
'created_by' => $actor?->id,
|
||||
], fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
if (empty($row['customer_id']) || empty($row['description'])) {
|
||||
throw ValidationException::withMessages(['complaint' => 'Pelanggan dan deskripsi wajib diisi.']);
|
||||
}
|
||||
|
||||
$client->insertRecord($this->table(), [$row], asUser: false);
|
||||
|
||||
$stored = $client->listRecords($this->table(), [
|
||||
'select' => 'id',
|
||||
'complaint_number' => 'eq.'.$number,
|
||||
'limit' => 1,
|
||||
], asUser: $asUser)[0] ?? null;
|
||||
|
||||
$this->activity->log('complaint_created', 'complaints', $stored['id'] ?? null, "Komplain {$number} dicatat.", $actor);
|
||||
|
||||
return ['id' => $stored['id'] ?? null, 'complaint_number' => $number];
|
||||
}
|
||||
|
||||
public function resolve(string $complaintId, string $resolution, ?User $actor = null): void
|
||||
{
|
||||
[$client, $asUser] = $this->scoped();
|
||||
|
||||
$complaint = $client->getRecord($this->table(), $complaintId, asUser: $asUser)
|
||||
?? throw ValidationException::withMessages(['complaint' => 'Komplain tidak ditemukan.']);
|
||||
|
||||
if (($complaint['status'] ?? '') !== self::STATUS_OPEN) {
|
||||
throw ValidationException::withMessages(['complaint' => 'Komplain ini sudah ditutup.']);
|
||||
}
|
||||
|
||||
$isOwner = $actor !== null && $actor->id === ($complaint['created_by'] ?? null);
|
||||
|
||||
if (! ($actor?->isAdmin() ?? false) && ! $isOwner) {
|
||||
throw new AuthorizationException('Operator hanya dapat menutup komplain miliknya sendiri.');
|
||||
}
|
||||
|
||||
$client->updateRecords($this->table(), ['id' => $complaintId], [
|
||||
'status' => self::STATUS_RESOLVED,
|
||||
'resolution' => $resolution,
|
||||
'resolved_at' => now()->toISOString(),
|
||||
], asUser: false);
|
||||
|
||||
$this->activity->log(
|
||||
'complaint_resolved',
|
||||
'complaints',
|
||||
$complaintId,
|
||||
"Komplain {$complaint['complaint_number']} ditutup.",
|
||||
$actor,
|
||||
);
|
||||
}
|
||||
|
||||
public function canResolve(array $record, ?User $actor): bool
|
||||
{
|
||||
if (($record['status'] ?? '') !== self::STATUS_OPEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $actor !== null && ($actor->isAdmin() || $actor->id === ($record['created_by'] ?? null));
|
||||
}
|
||||
|
||||
/** @return array<string, string> id => ORD-... */
|
||||
public function orderOptions(): array
|
||||
{
|
||||
[$client] = $this->scoped();
|
||||
|
||||
return collect($client->listRecords('orders', [
|
||||
'select' => 'id,order_number',
|
||||
'deleted_at' => 'eq.null',
|
||||
'order' => 'created_at.desc',
|
||||
'limit' => 200,
|
||||
]))->mapWithKeys(fn (array $row): array => [$row['id'] => $row['order_number']])->all();
|
||||
}
|
||||
|
||||
private function nextComplaintNumber(): string
|
||||
{
|
||||
[$client, $asUser] = $this->scoped();
|
||||
|
||||
$prefix = 'CMP-'.now()->format('Ymd').'-';
|
||||
|
||||
$last = $client->listRecords($this->table(), [
|
||||
'select' => 'complaint_number',
|
||||
'complaint_number' => 'like.'.$prefix.'*',
|
||||
'order' => 'complaint_number.desc',
|
||||
'limit' => 1,
|
||||
], asUser: $asUser)[0]['complaint_number'] ?? null;
|
||||
|
||||
preg_match('/(\d+)$/', (string) $last, $matches);
|
||||
|
||||
return $prefix.sprintf('%04d', ((int) ($matches[1] ?? 0)) + 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user