dstpabuaran.com/app/Services/Admin/Master/Product/ProductVariantService.php
Yoga Pangestu b85bbb1ba4 feat: enhance purchase and restock management with variant support
- Added existing_material_ids to PurchaseForEdit and RestockForEdit types.
- Introduced RawMaterialVariant and ProductVariantForRestock types for better variant handling.
- Updated PurchaseCreate and PurchaseEdit components to fetch and display raw material variants.
- Enhanced RestockCreate and RestockEdit components to manage product variants dynamically.
- Modified TransactionCreate and TransactionEdit components to support product variants.
- Added routes for fetching active products and raw materials.
2026-08-20 10:48:24 +07:00

372 lines
14 KiB
PHP

<?php
namespace App\Services\Admin\Master\Product;
use App\Concerns\HasRoleChecks;
use App\Enums\PriceType;
use App\Enums\ProductStatus;
use App\Enums\Role;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Services\Concerns\LogsFormHistory;
use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use App\Services\StockMutationService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class ProductVariantService
{
use HasRoleChecks, LogsFormHistory, RegistersMedia;
public function __construct(
private S3PresignedService $s3Service,
private StockMutationService $stockMutationService,
) {}
public function getForStokOpname(): array
{
$products = Product::query()
->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));
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));
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(),
];
}
}