select(['id', 'name', 'status']) ->with([ 'productVariants:id,product_id,name,stock,reject_stock,retail_stock', ]) ->active() ->orderBy('name') ->get(); $allVariantIds = $products->pluck('productVariants.*.id')->flatten()->filter()->all(); if ($allVariantIds !== []) { $mediaByVariant = Media::query() ->whereIn('model_id', $allVariantIds) ->where('model_type', ProductVariant::class) ->where('collection_name', 'images') ->get() ->groupBy('model_id'); } else { $mediaByVariant = collect(); } return $products->each(function (Product $product) use ($mediaByVariant) { $product->productVariants->each(function (ProductVariant $variant) use ($mediaByVariant) { $media = $mediaByVariant->get($variant->id, collect())->first(); $variant->photo_url = $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null; }); })->toArray(); } public function getActiveProducts(): \Illuminate\Support\Collection { return Product::select(['id', 'name']) ->active() ->orderBy('name') ->get(); } public function getVariantsByProduct(Product $product): \Illuminate\Support\Collection { return $product->productVariants() ->select(['id', 'product_id', 'name', 'stock', 'reject_stock', 'retail_stock']) ->with(['productPrices:id,variant_id,type,price', 'media']) ->get() ->each(function (ProductVariant $variant) { $media = $variant->getFirstMedia('images'); $variant->photo_url = $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null; $variant->photo_conversion_url = $media ? ($media->getGeneratedConversions()->contains('thumb') ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) : $this->s3Service->getTemporaryUrl($media->getPath())) : null; $prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]); $variant->prices = $prices; }); } public function getForRestock(): array { $products = Product::query() ->select(['id', 'name', 'status']) ->with([ 'productVariants:id,product_id,name,stock,reject_stock', 'productVariants.productPrices:id,variant_id,type,price', ]) ->active() ->orderBy('name') ->get(); $allVariantIds = $products->pluck('productVariants.*.id')->flatten()->filter()->all(); if ($allVariantIds !== []) { $mediaByVariant = Media::query() ->whereIn('model_id', $allVariantIds) ->where('model_type', ProductVariant::class) ->where('collection_name', 'images') ->get() ->groupBy('model_id'); } else { $mediaByVariant = collect(); } return $products->each(function (Product $product) use ($mediaByVariant) { $product->productVariants->each(function (ProductVariant $variant) use ($mediaByVariant) { $media = $mediaByVariant->get($variant->id, collect())->first(); $variant->photo_url = $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null; $capitalPrice = $variant->productPrices ->first(fn ($price) => $price->type === PriceType::CAPITAL); $variant->capital_price = $capitalPrice?->price ?? 0; $rejectPrice = $variant->productPrices ->first(fn ($price) => $price->type === PriceType::REJECT_CAPITAL); $variant->reject_price = $rejectPrice?->price ?? 0; }); })->toArray(); } public function getForTransaction(): array { $products = Product::query() ->select(['id', 'name', 'status']) ->with([ 'productVariants:id,product_id,name,stock,reject_stock,retail_stock', 'productVariants.productPrices:id,variant_id,type,price', ]) ->active() ->orderBy('name') ->get(); $allVariantIds = $products->pluck('productVariants.*.id')->flatten()->filter()->all(); if ($allVariantIds !== []) { $mediaByVariant = Media::query() ->whereIn('model_id', $allVariantIds) ->where('model_type', ProductVariant::class) ->where('collection_name', 'images') ->get() ->groupBy('model_id'); } else { $mediaByVariant = collect(); } return $products->each(function (Product $product) use ($mediaByVariant) { $product->productVariants->each(function (ProductVariant $variant) use ($mediaByVariant) { $media = $mediaByVariant->get($variant->id, collect())->first(); $variant->photo_url = $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null; $prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]); $variant->prices = $prices; }); })->toArray(); } public function getForEdit(ProductVariant $variant): array { $variant->load([ 'productPrices', 'media', ]); $media = $variant->getMedia('images'); $photoKeys = $media->map(fn ($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray(); $photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray(); return [ 'id' => $variant->id, 'product_id' => $variant->product_id, 'name' => $variant->name, 'stock' => $variant->stock, 'reject_stock' => $variant->reject_stock, 'retail_stock' => $variant->retail_stock, 'photo_keys' => $photoKeys, 'photo_urls' => $photoUrls, 'prices' => $variant->productPrices->map(fn ($p) => [ 'type' => $p->type->value, 'price' => $p->price, ]), ]; } public function update(ProductVariant $variant, array $data): ProductVariant { $this->assertNotPending($variant->product); $oldValues = $this->getProductLogValues($variant->product); DB::transaction(function () use ($variant, $data) { $oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']); $variant->update([ 'name' => $data['name'], 'stock' => $data['stock'], 'reject_stock' => $data['reject_stock'], 'retail_stock' => $data['retail_stock'], ]); $this->stockMutationService->recordAdjustment($variant, $oldData, $data, 'Penyesuaian stok saat edit varian'); // Upsert prices instead of delete+recreate $priceRows = collect($data['prices'])->map(fn ($p) => [ 'variant_id' => $variant->id, 'type' => $p['type'], 'price' => $p['price'], 'created_at' => now(), 'updated_at' => now(), ])->toArray(); DB::table('product_prices')->upsert( $priceRows, ['variant_id', 'type'], ['price', 'updated_at'] ); if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) { $this->syncPhotos($variant, $data['photo_keys']); } }); NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: 'Varian Diperbarui', body: "Varian \"{$variant->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.products.index', ['highlight' => $variant->product_id]), ); $product = $variant->product()->with(['categories:id,name', 'productVariants.productPrices'])->first(); $this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product), ['Nama Produk']); return $variant->fresh(); } public function destroy(Product $product, ProductVariant $variant): bool { $this->assertNotPending($product); $oldValues = $this->getProductLogValues($product); $result = DB::transaction(function () use ($variant) { return $variant->delete(); }); NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: 'Varian Dihapus', body: "Varian \"{$variant->name}\" dari produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.products.index'), ); $product->refresh()->load(['categories:id,name', 'productVariants.productPrices']); $this->logDeleted($product, 'Produk', $oldValues); return $result; } public function transferStock(ProductVariant $variant, array $data): ProductVariant { $quantity = (int) $data['quantity']; $this->assertNotPending($variant->product); $oldValues = $this->getProductLogValues($variant->product); if ($variant->stock < $quantity) { throw ValidationException::withMessages([ 'quantity' => "Stok bagus tidak mencukupi. Stok tersedia: {$variant->stock}.", ]); } DB::transaction(function () use ($variant, $quantity, $data) { $stockBefore = $variant->stock; $retailBefore = $variant->retail_stock; $variant->update([ 'stock' => $stockBefore - $quantity, 'retail_stock' => $retailBefore + $quantity, ]); $this->stockMutationService->recordTransfer( model: $variant, quantity: $quantity, fromQuality: 'good', toQuality: 'retail', fromBefore: $stockBefore, toBefore: $retailBefore, description: $data['description'] ?? 'Transfer stok bagus ke stok ecer', ); }); NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: 'Transfer Stok', body: "{$quantity} unit dari varian \"{$variant->name}\" berhasil ditransfer dari stok bagus ke stok ecer".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.products.index', ['highlight' => $variant->product_id]), ); $product = $variant->product()->with(['categories:id,name', 'productVariants.productPrices'])->first(); $this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product), ['Nama Produk']); return $variant->fresh(); } private function assertNotPending(Product $product): void { if ($product->status === ProductStatus::PENDING && ! self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) { throw ValidationException::withMessages([ 'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.', ]); } } private function getProductLogValues(Product $product): array { $product->load(['categories:id,name', 'productVariants.productPrices']); $priceTypeLabels = [ 'distributor' => 'Harga Distributor', 'agent' => 'Harga Agen', 'sub_agent' => 'Harga Sub Agen', 'wholesale' => 'Harga Grosir', 'retail' => 'Harga Ecer', 'tiktok' => 'Harga TikTok', 'shopee' => 'Harga Shopee', 'capital' => 'Harga Modal', 'reject_capital' => 'Harga Reject Modal', 'reject_selling' => 'Harga Reject Jual', ]; return [ 'Nama Produk' => $product->name, 'Status' => $product->status?->label(), 'Unggulan' => $this->formatBoolean($product->is_featured), 'Kategori' => $product->categories->pluck('name')->toArray(), 'Deskripsi' => $product->description, 'Varian' => $product->productVariants->map(function ($variant) use ($priceTypeLabels) { $variantData = [ 'Nama Varian' => $variant->name, 'Stok Bagus' => $variant->stock, 'Stok Reject' => $variant->reject_stock, 'Stok Retail' => $variant->retail_stock, ]; foreach ($variant->productPrices as $price) { $label = $priceTypeLabels[$price->type->value] ?? $price->type->label(); $variantData[$label] = $this->formatCurrency($price->price); } return $variantData; })->toArray(), ]; } }