where('subject_type', MarketplaceSettings::class) ->pending() ->exists(); } public function paginateForIndex( User $user, array $tableQuery, string $status = '', string $subjectType = '', string $action = '', ): LengthAwarePaginator { $perPage = 25; $page = Paginator::resolveCurrentPage(); $includeCuttings = $this->shouldIncludeCuttings($user, $status, $subjectType, $action); $cuttingRows = $includeCuttings ? $this->pendingCuttingRows($user, $tableQuery) : collect(); $cuttingCount = $cuttingRows->count(); $requestsOnly = $action === OwnerVerificationAction::STOCK_VERIFY->value || $subjectType === Cutting::class; $query = $this->buildVerificationRequestQuery($user, $tableQuery, $status, $subjectType, $action); $requestTotal = $requestsOnly ? 0 : (clone $query)->count(); $total = $cuttingCount + $requestTotal; if ($requestsOnly) { $items = $cuttingRows->forPage($page, $perPage)->values(); return $this->makePaginator($items, $total, $perPage, $page); } if ($cuttingCount > 0) { if ($page === 1) { $requestLimit = max(0, $perPage - $cuttingCount); $requestItems = $requestLimit > 0 ? $query->take($requestLimit)->get()->map(fn (OwnerVerificationRequest $request) => $this->presentRequestRow($request)) : collect(); $items = $cuttingRows->concat($requestItems)->values(); } else { $requestOffset = ($page - 1) * $perPage - $cuttingCount; $items = $query ->skip(max(0, $requestOffset)) ->take($perPage) ->get() ->map(fn (OwnerVerificationRequest $request) => $this->presentRequestRow($request)); } return $this->makePaginator($items, $total, $perPage, $page); } return $query ->paginate($perPage) ->withQueryString() ->through(fn (OwnerVerificationRequest $request) => $this->presentRequestRow($request)); } private function pendingCuttingRows(User $user, array $tableQuery): Collection { $rows = $this->stockService ->getPendingApprovalCuttings($user) ->map(fn (Cutting $cutting) => $this->presentCuttingRow($cutting)); if ($tableQuery['search'] === '') { return $rows->values(); } $search = mb_strtolower($tableQuery['search']); return $rows ->filter(function (array $row) use ($search): bool { foreach (['title', 'summary', 'submitted_by_name', 'action_label', 'subject_label', 'status_label'] as $field) { $value = mb_strtolower((string) ($row[$field] ?? '')); if ($value !== '' && str_contains($value, $search)) { return true; } } return false; }) ->values(); } public function pendingCountForUser(User $user): int { return $this->cacheRemember("verification:pending_count:{$user->id}", 30, function () use ($user) { $requestCount = OwnerVerificationRequest::query() ->pending() ->visibleTo($user) ->count(); if (! $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { return $requestCount; } return $requestCount + Cutting::query()->pendingVerification()->count(); }); } public function subjectTypeOptions(User $user): array { $options = OwnerVerificationRequest::query() ->visibleTo($user) ->distinct() ->pluck('subject_type') ->filter() ->map(fn (string $type) => [ 'value' => $type, 'label' => ModelLabel::for($type), ]); if ($user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value) && Cutting::query()->pendingVerification()->exists()) { $options->push([ 'value' => Cutting::class, 'label' => ModelLabel::for(Cutting::class), ]); } return $options ->unique('value') ->sortBy('label') ->values() ->all(); } public function approveRequest( OwnerVerificationRequest $request, User $user, ?string $approvalNote = null, ): void { if ($request->status !== OwnerVerificationStatus::PENDING) { throw ValidationException::withMessages([ 'status' => 'Hanya pengajuan yang menunggu verifikasi yang dapat disetujui.', ]); } try { DB::transaction(function () use ($request, $user, $approvalNote): void { $this->applyVerificationRequest($request); if ($approvalNote !== null && trim($approvalNote) !== '') { $request->rejection()->create([ 'reason' => trim($approvalNote), 'rejected_by_id' => $user->id, ]); } $request->update([ 'status' => OwnerVerificationStatus::APPROVED, 'verified_by_id' => $user->id, 'verified_at' => now(), ]); $this->clearVerificationRequestMedia($request); }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal menyetujui verifikasi owner: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $subjectLabel = ModelLabel::for($request->subject_type); $this->notifyRequestSubmitter( $request, '✅ Pengajuan Disetujui', "Pengajuan {$request->action->label()} {$subjectLabel} '{$this->requestTitle($request)}' telah disetujui owner.", ); $this->cacheForgetByPattern('verification:*'); } public function rejectRequest( OwnerVerificationRequest $request, User $user, string $reason, ): void { if ($request->status !== OwnerVerificationStatus::PENDING) { throw ValidationException::withMessages([ 'status' => 'Hanya pengajuan yang menunggu verifikasi yang dapat ditolak.', ]); } try { DB::transaction(function () use ($request, $user, $reason): void { $this->rejectVerificationRequest($request); $request->rejection()->create([ 'reason' => trim($reason), 'rejected_by_id' => $user->id, ]); $request->update([ 'status' => OwnerVerificationStatus::REJECTED, 'verified_by_id' => $user->id, 'verified_at' => now(), ]); $this->clearVerificationRequestMedia($request); }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal menolak verifikasi owner: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $title = $this->requestTitle($request); $subjectLabel = ModelLabel::for($request->subject_type); $this->pushNotificationService->sendToRoles( '❌ Pengajuan Ditolak', "Pengajuan {$request->action->label()} {$subjectLabel} '{$title}' ditolak dengan alasan: '{$reason}'.", ['owner', 'developer', 'direktur'], route('admin.dashboard'), ); $this->notifyRequestSubmitter( $request, '❌ Pengajuan Ditolak', "Pengajuan {$request->action->label()} {$subjectLabel} '{$title}' ditolak dengan alasan: '{$reason}'.", ); $this->cacheForgetByPattern('verification:*'); } private function rejectVerificationRequest(OwnerVerificationRequest $request): void { match ($request->subject_type) { Product::class => $this->productService->rejectVerificationRequest($request), RawMaterial::class => $this->rawMaterialService->rejectVerificationRequest($request), Purchase::class => $this->purchaseService->rejectVerificationRequest($request), MarketplaceSettings::class => null, default => throw ValidationException::withMessages([ 'subject_type' => 'Tipe data verifikasi tidak didukung.', ]), }; } private function applyVerificationRequest(OwnerVerificationRequest $request): void { match ($request->subject_type) { Product::class => $this->productService->applyVerificationRequest($request), RawMaterial::class => $this->rawMaterialService->applyVerificationRequest($request), Purchase::class => $this->purchaseService->applyVerificationRequest($request), MarketplaceSettings::class => $this->marketplaceService->applyVerificationRequest($request), default => throw ValidationException::withMessages([ 'subject_type' => 'Tipe data verifikasi tidak didukung.', ]), }; } private function clearVerificationRequestMedia(OwnerVerificationRequest $request): void { match ($request->subject_type) { Product::class => $this->productService->clearVerificationRequestMedia($request), RawMaterial::class => $this->rawMaterialService->clearVerificationRequestMedia($request), Purchase::class => $this->purchaseService->clearVerificationRequestMedia($request), default => null, }; } private function presentRequestRow(OwnerVerificationRequest $request): array { $payload = is_array($request->payload) ? $request->payload : []; return [ 'id' => $request->id, 'source' => 'request', 'subject_type' => $request->subject_type, 'subject_label' => ModelLabel::for($request->subject_type), 'subject_id' => $request->subject_id, 'action' => $request->action->value, 'action_label' => $request->action->label(), 'status' => $request->status->value, 'status_label' => $request->status->label(), 'title' => $this->requestTitle($request), 'submitted_by_name' => $request->submittedBy?->profile?->full_name ?? $request->submittedBy?->username ?? '-', 'verified_by_name' => $request->verifiedBy?->profile?->full_name ?? $request->verifiedBy?->username, 'verified_at_formatted' => $request->verified_at?->translatedFormat('l, d F Y H:i'), 'created_at' => $request->created_at?->toIso8601String(), 'created_at_formatted' => $request->created_at?->translatedFormat('l, d F Y H:i'), 'changes' => VerificationChangeFormatter::format($payload), 'rejection_reason' => $request->rejection?->reason, 'is_pending' => $request->status === OwnerVerificationStatus::PENDING, ]; } private function presentCuttingRow(Cutting $cutting): array { $totalPieces = $cutting->results->sum('cutting_result'); return [ 'id' => $cutting->id, 'source' => 'cutting', 'subject_type' => Cutting::class, 'subject_label' => ModelLabel::for(Cutting::class), 'subject_id' => $cutting->id, 'action' => OwnerVerificationAction::STOCK_VERIFY->value, 'action_label' => OwnerVerificationAction::STOCK_VERIFY->label(), 'status' => OwnerVerificationStatus::PENDING->value, 'status_label' => OwnerVerificationStatus::PENDING->label(), 'title' => "Cutting #{$cutting->id}", 'summary' => $cutting->description ?? "Total hasil {$totalPieces} pcs", 'submitted_by_name' => $cutting->submittedBy?->profile?->full_name ?? $cutting->submittedBy?->username ?? '-', 'created_at' => $cutting->created_at?->toIso8601String(), 'created_at_formatted' => $cutting->created_at?->translatedFormat('l, d F Y H:i'), 'changes' => [], 'is_pending' => true, 'detail' => [ 'results' => $cutting->results, 'result_prices' => $cutting->result_prices, 'total_result_pieces' => $cutting->total_result_pieces, ], ]; } private function buildVerificationRequestQuery( User $user, array $tableQuery, string $status, string $subjectType, string $action, ): Builder { if ($action === OwnerVerificationAction::STOCK_VERIFY->value || $subjectType === Cutting::class) { return OwnerVerificationRequest::query()->whereRaw('0 = 1'); } $query = OwnerVerificationRequest::query() ->visibleTo($user) ->with([ 'subject', 'submittedBy.profile', 'verifiedBy.profile', 'rejection', ]) ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { $search = $tableQuery['search']; $query->where(function (Builder $query) use ($search): void { $query->where('action', 'like', "%{$search}%") ->orWhere('status', 'like', "%{$search}%") ->orWhereHas('submittedBy', function (Builder $query) use ($search): void { $query->where('username', 'like', "%{$search}%") ->orWhereHas('profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%")); }) ->orWhereHasMorph('subject', [Product::class, RawMaterial::class, Purchase::class, ProductVariant::class], function (Builder $query, string $type) use ($search): void { if ($type === Purchase::class) { $query->where('notes', 'like', "%{$search}%") ->orWhereHas('supplier', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")); return; } $query->where('name', 'like', "%{$search}%"); }); }); }) ->when($status !== '', fn (Builder $query) => $query->where('status', $status)) ->when($subjectType !== '', fn (Builder $query) => $query->where('subject_type', $subjectType)) ->when($action !== '', fn (Builder $query) => $query->where('action', $action)); $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); return $query; } private function shouldIncludeCuttings( User $user, string $status, string $subjectType, string $action, ): bool { if (! $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { return false; } if ($status !== '' && $status !== OwnerVerificationStatus::PENDING->value) { return false; } if ($subjectType !== '' && $subjectType !== Cutting::class) { return false; } if ($action !== '' && $action !== OwnerVerificationAction::STOCK_VERIFY->value) { return false; } return Cutting::query()->pendingVerification()->exists(); } private function makePaginator( Collection $items, int $total, int $perPage, int $page, ): LengthAwarePaginator { return (new Paginator( $items, $total, $perPage, $page, ['path' => Paginator::resolveCurrentPath(), 'pageName' => 'page'], ))->withQueryString(); } private function requestTitle(OwnerVerificationRequest $request): string { if ($request->subject_type === MarketplaceSettings::class) { return 'Pengaturan Marketplace'; } if ($request->subject instanceof Product || $request->subject instanceof RawMaterial) { return $request->subject->name; } if ($request->subject instanceof Purchase) { $request->subject->loadMissing('supplier'); return $request->subject->supplier?->name ?? 'Belanja'; } if ($request->subject instanceof ProductVariant) { $payload = is_array($request->payload) ? $request->payload : []; $newData = $payload['new'] ?? []; return ($newData['product_name'] ?? 'Produk').' - '.($newData['variant_name'] ?? $request->subject->name); } $payload = is_array($request->payload) ? $request->payload : []; foreach (['new', 'old'] as $key) { $section = $payload[$key] ?? null; if (is_array($section) && isset($section['supplier_name'])) { return (string) $section['supplier_name']; } } foreach (['new', 'old'] as $key) { $section = $payload[$key] ?? null; if (is_array($section) && isset($section['name'])) { return (string) $section['name']; } } if (isset($payload['name'])) { return (string) $payload['name']; } return 'Item Baru'; } private function requestSearchTerm(OwnerVerificationRequest $request): ?string { if ($request->subject_type === Product::class) { if ($request->subject instanceof Product) { return $request->subject->name; } $payload = is_array($request->payload) ? $request->payload : []; foreach (['new', 'old'] as $key) { $section = $payload[$key] ?? null; if (is_array($section) && isset($section['name'])) { return (string) $section['name']; } } } if ($request->subject_type === ProductVariant::class) { $payload = is_array($request->payload) ? $request->payload : []; $newData = $payload['new'] ?? []; if (isset($newData['product_name']) && $newData['product_name'] !== '-') { return $newData['product_name']; } if ($request->subject instanceof ProductVariant) { return $request->subject->product?->name ?? $request->subject->name; } } if ($request->subject_type === RawMaterial::class) { if ($request->subject instanceof RawMaterial) { return $request->subject->name; } $payload = is_array($request->payload) ? $request->payload : []; foreach (['new', 'old'] as $key) { $section = $payload[$key] ?? null; if (is_array($section) && isset($section['name'])) { return (string) $section['name']; } } } if ($request->subject_type === Purchase::class) { if ($request->subject instanceof Purchase) { $request->subject->loadMissing('supplier'); return $request->subject->supplier?->name; } $payload = is_array($request->payload) ? $request->payload : []; foreach (['new', 'old'] as $key) { $section = $payload[$key] ?? null; if (is_array($section) && isset($section['supplier_name'])) { return (string) $section['supplier_name']; } } } return null; } private function notifyRequestSubmitter( OwnerVerificationRequest $request, string $title, string $body, ): void { if (! $request->submitted_by_id) { return; } $searchTerm = $this->requestSearchTerm($request); $routeParams = $searchTerm !== null ? ['search' => $searchTerm] : []; $url = match ($request->subject_type) { Product::class, ProductVariant::class => route('admin.master.products.index', $routeParams), RawMaterial::class => route('admin.master.raw_materials.index', $routeParams), Purchase::class => route('admin.manage.purchases.index', $routeParams), MarketplaceSettings::class => route('admin.system.settings.index'), default => route('admin.dashboard'), }; $this->pushNotificationService->sendToUser( $title, $body, $request->submitted_by_id, $url, ); } private function applySorting(Builder $query, string $sort, string $direction): void { if (in_array($sort, ['created_at', 'action', 'status'], true)) { $query->orderBy($sort, $direction); return; } $query->latest(); } }