with([ 'categories', 'pendingOwnerVerificationRequest', 'variants' => fn ($query) => $query ->with(['media', 'prices' => fn ($query) => $query->orderBy('type')]) ->orderBy('created_at'), ]) ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { $search = $tableQuery['search']; $query->where(function (Builder $query) use ($search): void { $query->where('name', 'like', "%{$search}%") ->orWhere('slug', 'like', "%{$search}%") ->orWhere('description', 'like', "%{$search}%") ->orWhereHas('categories', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")) ->orWhereHas('variants', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")); }); }) ->when($isActive !== '', fn (Builder $query) => $query->where('is_active', $isActive === '1')) ->when($categoryId !== '', function (Builder $query) use ($categoryId): void { $query->whereHas('categories', fn (Builder $query) => $query->where('categories.id', $categoryId)); }) ->when($stockStatus === 'out_of_stock', function (Builder $query): void { $query->whereHas('variants', fn (Builder $variantQuery) => $variantQuery->where('stock', '<=', 0)); }) ->when($stockStatus === 'low_stock', function (Builder $query): void { $query->whereHas('variants', fn (Builder $variantQuery) => $variantQuery->where('stock', '>', 0)->where('stock', '<', ProductVariant::minStock())); }); $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); return $query ->paginate(25) ->withQueryString() ->through(function (Product $product) { $product->variants->each(function (ProductVariant $variant): void { $variant->setAttribute( 'images', MediaPresenter::collection($variant, 'images'), ); }); $pendingRequest = $product->pendingOwnerVerificationRequest; // Also check for pending stock retail transfer on variants if ($pendingRequest === null) { $pendingRequest = OwnerVerificationRequest::query() ->where('subject_type', ProductVariant::class) ->whereIn('subject_id', $product->variants->pluck('id')) ->where('action', OwnerVerificationAction::STOCK_RETAIL_TRANSFER) ->pending() ->latest() ->first(); } $product->setAttribute('has_pending_request', $pendingRequest !== null); $product->setAttribute('pending_request_id', $pendingRequest?->id); $product->setAttribute('pending_request_action', $pendingRequest?->action->value); $product->setAttribute('pending_request_action_label', $pendingRequest?->action->label()); $product->setAttribute( 'display_is_active', $pendingRequest?->pendingToggleIsActive() ?? $product->is_active, ); return $product; }); } public function findForEdit(Product $product): Product { $product->load([ 'categories', 'variants' => fn ($query) => $query ->with('media') ->orderBy('created_at'), ]); $product->variants->each(function (ProductVariant $variant): void { $variant->setAttribute('images', MediaPresenter::collection($variant, 'images')); }); return $product; } public function create(array $validated, User $user): void { try { DB::transaction(function () use ($validated, $user): void { $product = Product::create([ 'name' => $validated['name'], 'description' => $validated['description'] ?? null, 'is_active' => false, ]); $product->categories()->sync($validated['category_ids'] ?? []); foreach ($validated['variants'] as $index => $variantData) { $variant = $product->variants()->create([ 'name' => $variantData['name'], 'stock' => $variantData['stock'], 'stock_retail' => $variantData['stock_retail'], ]); $this->syncVariantImages($variant, $variantData, $index); } OwnerVerificationRequest::create([ 'action' => OwnerVerificationAction::CREATE, 'status' => OwnerVerificationStatus::PENDING, 'subject_type' => Product::class, 'subject_id' => $product->id, 'submitted_by_id' => $user->id, 'payload' => [ 'old' => null, 'new' => $this->snapshotProduct($product->fresh(['categories', 'variants'])), ], ]); }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal mengajukan pembuatan produk: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $this->notifyForPendingRequest( $user, 'Tambah Produk', "Pengajuan tambah produk '{$validated['name']}' menunggu verifikasi owner.", route('admin.master.products.index'), ); } public function update(Product $product, array $validated, User $user): void { try { DB::transaction(function () use ($validated, $product, $user): void { $verificationRequest = OwnerVerificationRequest::create([ 'action' => OwnerVerificationAction::UPDATE, 'status' => OwnerVerificationStatus::PENDING, 'subject_type' => Product::class, 'subject_id' => $product->id, 'submitted_by_id' => $user->id, 'payload' => [ 'old' => $this->snapshotProduct($product), 'new' => $this->enrichPayload($this->buildPayloadFromValidated($validated)), ], ]); foreach ($validated['variants'] as $index => $variantData) { $isNewVariant = empty($variantData['id']); $this->syncRequestVariantImages($verificationRequest, $variantData, $index, required: $isNewVariant); } }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal mengajukan perubahan produk: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $this->notifyForPendingRequest( $user, 'Ubah Produk', "Pengajuan ubah produk '{$product->name}' menunggu verifikasi owner.", route('admin.master.products.index'), ); } public function delete(Product $product, User $user): void { try { OwnerVerificationRequest::create([ 'action' => OwnerVerificationAction::DELETE, 'status' => OwnerVerificationStatus::PENDING, 'subject_type' => Product::class, 'subject_id' => $product->id, 'submitted_by_id' => $user->id, 'payload' => [ 'old' => $this->snapshotProduct($product), 'new' => null, ], ]); } catch (\Throwable $e) { Log::error('Gagal mengajukan penghapusan produk: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $this->notifyForPendingRequest( $user, 'Hapus Produk', "Pengajuan hapus produk '{$product->name}' menunggu verifikasi owner.", route('admin.master.products.index'), ); } public function toggleStatus(Product $product, array $validated, User $user): void { try { OwnerVerificationRequest::create([ 'action' => OwnerVerificationAction::TOGGLE_STATUS, 'status' => OwnerVerificationStatus::PENDING, 'subject_type' => Product::class, 'subject_id' => $product->id, 'submitted_by_id' => $user->id, 'payload' => [ 'old' => [ 'name' => $product->name, 'is_active' => $product->is_active, ], 'new' => [ 'name' => $product->name, 'is_active' => (bool) $validated['is_active'], ], ], ]); } catch (\Throwable $e) { Log::error('Gagal mengajukan perubahan status produk: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif'; $this->notifyForPendingRequest( $user, 'Ubah Status Produk', "Pengajuan ubah status produk '{$product->name}' menjadi {$statusLabel} menunggu verifikasi owner.", route('admin.master.products.index'), ); } public function applyVerificationRequest(OwnerVerificationRequest $verificationRequest): void { match ($verificationRequest->action) { OwnerVerificationAction::CREATE => $this->applyCreate($verificationRequest), OwnerVerificationAction::UPDATE => $this->applyUpdate($verificationRequest), OwnerVerificationAction::DELETE => $this->applyDelete($verificationRequest), OwnerVerificationAction::TOGGLE_STATUS => $this->applyToggleStatus($verificationRequest), default => throw ValidationException::withMessages([ 'action' => 'Aksi verifikasi produk tidak didukung.', ]), }; } public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void { match ($verificationRequest->action) { OwnerVerificationAction::CREATE => $this->rejectCreate($verificationRequest), OwnerVerificationAction::UPDATE => $this->rollbackUpdate($verificationRequest), OwnerVerificationAction::DELETE, OwnerVerificationAction::TOGGLE_STATUS => null, default => throw ValidationException::withMessages([ 'action' => 'Aksi verifikasi produk tidak didukung.', ]), }; } public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void { $verificationRequest->load('media'); $newPayload = $this->payloadNew($verificationRequest); foreach ($newPayload['variants'] ?? [] as $index => $_variant) { $verificationRequest->clearMediaCollection($verificationRequest->variantImageCollection((int) $index)); } } public function applyCreate(OwnerVerificationRequest $verificationRequest): void { $product = $verificationRequest->subject; if (! $product instanceof Product) { throw ValidationException::withMessages([ 'product' => 'Produk tidak ditemukan.', ]); } $product->update([ 'is_active' => true, ]); } public function applyUpdate(OwnerVerificationRequest $verificationRequest): void { $product = $verificationRequest->subject; if (! $product instanceof Product) { throw ValidationException::withMessages([ 'product' => 'Produk tidak ditemukan.', ]); } $this->applyPayloadToProduct($product, $this->payloadNew($verificationRequest), $verificationRequest); } private function applyPayloadToProduct( Product $product, array $payload, ?OwnerVerificationRequest $verificationRequest = null, ): void { $product->update([ 'name' => $payload['name'], 'description' => $payload['description'] ?? null, ]); if (array_key_exists('is_active', $payload)) { $product->update([ 'is_active' => (bool) $payload['is_active'], ]); } $product->categories()->sync($payload['category_ids'] ?? []); $submittedVariantIds = collect($payload['variants'] ?? []) ->pluck('id') ->filter() ->map(fn ($id) => (int) $id) ->all(); $product->variants() ->whereNotIn('id', $submittedVariantIds) ->get() ->each(function (ProductVariant $variant): void { $variant->clearMediaCollection('images'); $variant->delete(); }); foreach ($payload['variants'] ?? [] as $index => $variantData) { if (! empty($variantData['id'])) { $variant = $product->variants()->findOrFail($variantData['id']); $variant->update([ 'name' => $variantData['name'], 'stock' => $variantData['stock'], 'stock_retail' => $variantData['stock_retail'], ]); if ($verificationRequest !== null) { $this->applyVariantImageChanges($verificationRequest, $variant, $variantData, (int) $index); } continue; } $variant = $product->variants()->create([ 'name' => $variantData['name'], 'stock' => $variantData['stock'], 'stock_retail' => $variantData['stock_retail'], ]); if ($verificationRequest !== null) { $this->copyRequestVariantImages($verificationRequest, (int) $index, $variant); } } } public function applyDelete(OwnerVerificationRequest $verificationRequest): void { $product = $verificationRequest->subject; if (! $product instanceof Product) { throw ValidationException::withMessages([ 'product' => 'Produk tidak ditemukan.', ]); } DB::transaction(function () use ($product): void { $product->variants()->each(function (ProductVariant $variant): void { $variant->clearMediaCollection('images'); }); $product->variants()->delete(); $product->categories()->detach(); $product->delete(); }); } public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void { $product = $verificationRequest->subject; if (! $product instanceof Product) { throw ValidationException::withMessages([ 'product' => 'Produk tidak ditemukan.', ]); } $newPayload = $this->payloadNew($verificationRequest); $product->update([ 'is_active' => (bool) ($newPayload['is_active'] ?? false), ]); } private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl): void { $this->pushNotificationService->sendToRoles( "📦 {$typeLabel} Menunggu Persetujuan Owner", $body, ['owner', 'developer'], route('admin.master.products.index'), ); $this->pushNotificationService->sendToUser( '📤 Pengajuan Terkirim', $body, $user->id, $submitterUrl, ); } private function rejectCreate(OwnerVerificationRequest $verificationRequest): void { $product = $verificationRequest->subject; if (! $product instanceof Product) { return; } DB::transaction(function () use ($product): void { $product->variants()->each(function (ProductVariant $variant): void { $variant->clearMediaCollection('images'); }); $product->variants()->delete(); $product->categories()->detach(); $product->delete(); }); } private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void { $product = $verificationRequest->subject; if (! $product instanceof Product) { throw ValidationException::withMessages([ 'product' => 'Produk tidak ditemukan.', ]); } $oldPayload = $this->payloadOld($verificationRequest); if ($oldPayload === []) { return; } $this->applyPayloadToProduct($product, $oldPayload); } private function payloadOld(OwnerVerificationRequest $verificationRequest): array { $payload = $verificationRequest->payload ?? []; if (array_key_exists('old', $payload)) { return is_array($payload['old']) ? $payload['old'] : []; } return []; } private function payloadNew(OwnerVerificationRequest $verificationRequest): array { $payload = $verificationRequest->payload ?? []; if (array_key_exists('new', $payload)) { return is_array($payload['new']) ? $payload['new'] : []; } return $payload; } private function snapshotProduct(Product $product): array { $product->load(['categories', 'variants']); return $this->enrichPayload([ 'name' => $product->name, 'description' => $product->description, 'category_ids' => $product->categories->pluck('id')->all(), 'is_active' => $product->is_active, 'variants' => $product->variants ->map(fn (ProductVariant $variant) => [ 'id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock, 'stock_retail' => $variant->stock_retail, ]) ->all(), ]); } private function enrichPayload(array $data): array { $categoryIds = $data['category_ids'] ?? []; $data['category_names'] = $categoryIds === [] ? [] : Category::query()->whereIn('id', $categoryIds)->pluck('name')->all(); return $data; } private function buildPayloadFromValidated(array $validated): array { return [ 'name' => $validated['name'], 'description' => $validated['description'] ?? null, 'category_ids' => $validated['category_ids'] ?? [], 'variants' => collect($validated['variants'] ?? []) ->map(fn (array $variantData) => [ 'id' => $variantData['id'] ?? null, 'name' => $variantData['name'], 'stock' => $variantData['stock'], 'stock_retail' => $variantData['stock_retail'], 'remove_media_ids' => $variantData['remove_media_ids'] ?? [], ]) ->all(), ]; } private function syncVariantImages( ProductVariant $variant, array $variantData, int $index, ): void { $this->mediaService->syncCollection( $variant, 'images', $variantData['images'] ?? null, $variantData['remove_media_ids'] ?? null, self::MAX_VARIANT_IMAGES, required: true, errorKey: "variants.{$index}.images", ); } private function syncRequestVariantImages( OwnerVerificationRequest $verificationRequest, array $variantData, int $index, bool $required = true, ): void { $collection = $verificationRequest->variantImageCollection($index); $this->mediaService->syncCollection( $verificationRequest, $collection, $variantData['images'] ?? null, $variantData['remove_media_ids'] ?? null, self::MAX_VARIANT_IMAGES, required: $required, errorKey: "variants.{$index}.images", ); } private function applyVariantImageChanges( OwnerVerificationRequest $verificationRequest, ProductVariant $variant, array $variantData, int $index, ): void { $removeIds = $variantData['remove_media_ids'] ?? []; if ($removeIds !== []) { $variant->getMedia('images') ->whereIn('id', $removeIds) ->each->delete(); } $this->copyRequestVariantImages($verificationRequest, $index, $variant); if ($variant->getMedia('images')->isEmpty()) { throw ValidationException::withMessages([ "variants.{$index}.images" => 'Foto varian wajib diisi.', ]); } } private function copyRequestVariantImages(OwnerVerificationRequest $verificationRequest, int $index, ProductVariant $variant): void { $verificationRequest->getMedia($verificationRequest->variantImageCollection($index)) ->each(fn ($media) => $media->copy($variant, 'images')); } private function applySorting(Builder $query, string $sort, string $direction): void { if (in_array($sort, ['name', 'slug', 'is_active'], true)) { $query->orderBy($sort, $direction); return; } $query->latest(); } }