id) ->orderByDesc('id') ->get(); } /** * ========================= * VALIDATION RULE ENGINE * ========================= */ private function type(Ticket $ticket) { return $ticket->ticketType; } private function ensureRequiredAssets(Ticket $ticket, array $payload = []) { $type = $this->type($ticket); // PHOTO VALIDATION if ($type?->require_photo) { $photoCount = $payload['photo_count'] ?? ($ticket->customer?->images()->count() ?? 0); if ($photoCount <= 0) { throw ValidationException::withMessages([ 'photo' => 'Foto wajib diupload untuk ticket ini' ]); } } // MATERIAL VALIDATION if ($type?->require_material) { $materialCount = $payload['material_count'] ?? \App\Models\TicketMaterial::where('ticket_id', $ticket->id)->count(); if ($materialCount <= 0) { throw ValidationException::withMessages([ 'material' => 'Material wajib diinput untuk ticket ini' ]); } } } private function ensureCustomer(Ticket $ticket) { $type = $this->type($ticket); if ($type?->need_customer && empty($ticket->customer_id)) { throw ValidationException::withMessages([ 'customer_id' => 'Customer wajib ada untuk ticket ini' ]); } } /** * ========================= * ASSIGN / REASSIGN * ========================= */ public function assign( Ticket $ticket, array $technicians, int $leaderId, int $userId ): 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, $technicians, $leaderId, $userId ) { foreach ($technicians as $technicianId) { TicketAssignment::create([ 'ticket_id' => $ticket->id, 'user_id' => $technicianId, 'is_leader' => ($technicianId == $leaderId), 'assigned_by' => $userId, 'assigned_at' => now(), 'status' => 'assigned' ]); } $ticket->update([ 'status' => 'assigned', 'assigned_at' => now() ]); $this->log($ticket->id, $userId, 'Assign', 'Teknisi ditugaskan'); }); return $ticket->fresh(); } public function reassign( Ticket $ticket, array $technicians, int $leaderId, int $userId ): Ticket { DB::transaction(function () use ( $ticket, $technicians, $leaderId, $userId ) { TicketAssignment::where('ticket_id', $ticket->id)->delete(); foreach ($technicians as $technicianId) { TicketAssignment::create([ 'ticket_id' => $ticket->id, 'user_id' => $technicianId, 'is_leader' => ($technicianId == $leaderId), 'assigned_by' => $userId, 'assigned_at' => now(), 'status' => 'assigned' ]); } $this->log($ticket->id, $userId, 'Reassign', 'Penugasan diperbarui'); }); return $ticket->fresh(); } /** * ========================= * APPROVAL FLOW * ========================= */ public function approve( Ticket $ticket, ?string $notes, int $userId ): Ticket { TicketWorkflowValidator::allow($ticket, ['open']); DB::transaction(function () use ($ticket, $notes, $userId) { TicketApproval::create([ 'ticket_id' => $ticket->id, 'approver_id' => $userId, 'status' => 'approved', 'notes' => $notes, 'approved_at' => now(), 'created_at' => now() ]); $ticket->update([ 'status' => 'approved', 'approved_by' => $userId, 'approved_at' => now() ]); $this->log($ticket->id, $userId, 'Approved', $notes); }); return $ticket->fresh(); } public function reject(Ticket $ticket, string $reason, int $userId): Ticket { DB::transaction(function () use ($ticket, $reason, $userId) { TicketApproval::create([ 'ticket_id' => $ticket->id, 'approver_id' => $userId, 'status' => 'rejected', 'notes' => $reason, 'created_at' => now() ]); $ticket->update([ 'status' => 'rejected', 'rejected_by' => $userId, 'rejected_at' => now(), 'rejection_reason' => $reason ]); $this->log($ticket->id, $userId, 'Rejected', $reason); }); return $ticket->fresh(); } /** * ========================= * WORKFLOW START * ========================= */ 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' ]); $this->log( $ticket->id, $userId, 'Start Work', 'Pekerjaan dimulai' ); }); return $ticket->fresh(); } public function pending(Ticket $ticket, string $reason, int $userId, array $fileIds=[]): Ticket { 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'); return $ticket->fresh(); } /** * ========================= * RESOLVE (VALIDATION POINT) * ========================= */ public function resolve( Ticket $ticket, string $notes, int $userId, array $payload = [] ): 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, $payload) { $this->attachFiles($ticket,$payload['stored_file_ids']??[],'resolve',$userId,$notes); $ticket->update([ 'status' => 'resolved', 'resolved_at' => now() ]); $this->log($ticket->id, $userId, 'Resolved', $notes); }); return $ticket->fresh(); } 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', 'closed_at' => now() ]); $this->log($ticket->id, $userId, 'Closed', $notes); return $ticket->fresh(); } public function cancel(Ticket $ticket, string $reason, int $userId): Ticket { $ticket->update(['status' => 'cancelled']); $this->log($ticket->id, $userId, 'Cancelled', $reason); return $ticket->fresh(); } public function escalate(Ticket $ticket, string $reason, int $userId): Ticket { $this->log($ticket->id, $userId, 'Escalated', $reason); return $ticket->fresh(); } /** * ========================= * LOG SYSTEM * ========================= */ private function log( int $ticketId, int $userId, string $activity, ?string $notes = null ): void { TicketLog::create([ 'ticket_id' => $ticketId, 'user_id' => $userId, 'activity' => $activity, 'notes' => $notes, '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]);} } }