253 lines
9.1 KiB
PHP
253 lines
9.1 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\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, RegistersMedia;
|
|
|
|
public function __construct(
|
|
private S3PresignedService $s3Service,
|
|
private StockMutationService $stockMutationService,
|
|
) {}
|
|
|
|
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',
|
|
'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);
|
|
|
|
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]),
|
|
);
|
|
|
|
return $variant->fresh();
|
|
}
|
|
|
|
public function destroy(Product $product, ProductVariant $variant): bool
|
|
{
|
|
$this->assertNotPending($product);
|
|
|
|
$result = DB::transaction(function () use ($variant) {
|
|
$variant->productPrices()->delete();
|
|
|
|
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'),
|
|
);
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function transferStock(ProductVariant $variant, array $data): ProductVariant
|
|
{
|
|
$quantity = (int) $data['quantity'];
|
|
|
|
$this->assertNotPending($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]),
|
|
);
|
|
|
|
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.',
|
|
]);
|
|
}
|
|
}
|
|
}
|