feat: optimize product variant handling with bulk insertions and adjustments; enhance stock mutation recording

This commit is contained in:
Yoga Pangestu 2026-08-01 19:26:47 +07:00
parent 44dab8271f
commit 549e3e3bca
4 changed files with 284 additions and 54 deletions

View File

@ -15,7 +15,7 @@ protected function handleAction(callable $action, string $successMessage, string
$action();
Inertia::flash('toast', ['type' => 'success', 'message' => $successMessage]);
return to_route($redirectRoute, $parameters);
return to_route($redirectRoute);
} catch (ValidationException $e) {
$firstError = collect($e->errors())->flatten()->first();
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);

View File

@ -3,7 +3,6 @@
namespace App\Services\Admin\Master\Product;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
@ -90,31 +89,60 @@ public function create(array $data): Product
$useSamePrice = $data['use_same_price'] ?? false;
foreach ($data['variants'] as $index => $variantData) {
$variant = $product->productVariants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'reject_stock' => $variantData['reject_stock'],
'retail_stock' => $variantData['retail_stock'],
]);
// Bulk insert variants
$now = now();
$variantRows = collect($data['variants'])->map(fn ($v) => [
'product_id' => $product->id,
'name' => $v['name'],
'stock' => $v['stock'],
'reject_stock' => $v['reject_stock'],
'retail_stock' => $v['retail_stock'],
'created_at' => $now,
'updated_at' => $now,
])->toArray();
$prices = $useSamePrice
? $data['shared_prices']
: $variantData['prices'];
DB::table('product_variants')->insert($variantRows);
// Map variant name -> variant ID
$insertedVariants = ProductVariant::where('product_id', $product->id)->get();
$variantMap = $insertedVariants->mapWithKeys(fn ($v) => [$v->name => $v->id]);
// Bulk insert prices
$priceRows = [];
foreach ($data['variants'] as $variantData) {
$variantId = $variantMap[$variantData['name']];
$prices = $useSamePrice ? $data['shared_prices'] : $variantData['prices'];
foreach ($prices as $priceData) {
ProductPrice::create([
'variant_id' => $variant->id,
$priceRows[] = [
'variant_id' => $variantId,
'type' => $priceData['type'],
'price' => $priceData['price'],
]);
'created_at' => $now,
'updated_at' => $now,
];
}
}
if ($priceRows !== []) {
DB::table('product_prices')->insert($priceRows);
}
// Bulk record stock mutations
$stockDataMap = [];
foreach ($data['variants'] as $variantData) {
$variantId = $variantMap[$variantData['name']];
$stockDataMap[$variantId] = $variantData;
}
$this->stockMutationService->recordBulkInitial($insertedVariants, $stockDataMap, 'Stok awal saat pembuatan varian');
// Register photos (per-variant, S3 operation)
foreach ($data['variants'] as $variantData) {
if (! empty($variantData['photo_keys']) && is_array($variantData['photo_keys'])) {
$variant = $insertedVariants->firstWhere('name', $variantData['name']);
$this->variantService->registerPhotos($variant, $variantData['photo_keys']);
}
$this->stockMutationService->recordInitial($variant, $variantData, 'Stok awal saat pembuatan varian');
}
return $product;
@ -180,12 +208,15 @@ public function update(Product $product, array $data): Product
$product->categories()->sync($data['category_ids']);
$useSamePrice = $data['use_same_price'] ?? false;
$now = now();
// Identify existing vs new variants
$existingVariantIds = collect($data['variants'])
->pluck('id')
->filter()
->toArray();
// Delete removed variants (cascade prices + media)
$product->productVariants()
->whereNotIn('id', $existingVariantIds)
->each(function (ProductVariant $variant) {
@ -194,44 +225,162 @@ public function update(Product $product, array $data): Product
$variant->delete();
});
// Bulk load all existing variants with media (1 query instead of N)
$existingVariantsMap = ProductVariant::whereIn('id', $existingVariantIds)
->with('media')
->get()
->mapWithKeys(fn ($v) => [$v->id => $v]);
// Collect old stock data + identify changed variants
$oldStockDataMap = [];
$changedIds = [];
foreach ($data['variants'] as $variantData) {
$variantId = $variantData['id'] ?? null;
if (! $variantId || ! isset($existingVariantsMap[$variantId])) {
continue;
}
$variant = $existingVariantsMap[$variantId];
$oldStockDataMap[$variantId] = $variant->only(['stock', 'reject_stock', 'retail_stock']);
if ($variant->name !== $variantData['name']
|| $variant->stock != $variantData['stock']
|| $variant->reject_stock != $variantData['reject_stock']
|| $variant->retail_stock != $variantData['retail_stock']) {
$changedIds[] = $variantId;
}
}
// Bulk update changed variants (only changed ones, not all)
if ($changedIds !== []) {
$changedUpdates = collect($data['variants'])
->filter(fn ($v) => isset($v['id']) && in_array($v['id'], $changedIds));
foreach ($changedUpdates as $variantData) {
$existingVariantsMap[$variantData['id']]->update([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'reject_stock' => $variantData['reject_stock'],
'retail_stock' => $variantData['retail_stock'],
]);
}
}
// Bulk create new variants
$newVariantsData = collect($data['variants'])->filter(fn ($v) => ! isset($v['id']));
$newVariantIdMap = [];
if ($newVariantsData->isNotEmpty()) {
$newVariantRows = $newVariantsData->map(fn ($v) => [
'product_id' => $product->id,
'name' => $v['name'],
'stock' => $v['stock'],
'reject_stock' => $v['reject_stock'],
'retail_stock' => $v['retail_stock'],
'created_at' => $now,
'updated_at' => $now,
])->toArray();
DB::table('product_variants')->insert($newVariantRows);
// Map name -> id for new variants
$newlyCreated = ProductVariant::where('product_id', $product->id)
->whereIn('name', $newVariantsData->pluck('name')->toArray())
->get();
$newVariantIdMap = $newlyCreated->mapWithKeys(fn ($v) => [$v->name => $v->id])->toArray();
}
// Build variant_id lookup: existing by id, new by name
$variantIdLookup = [];
foreach ($data['variants'] as $variantData) {
$variantId = $variantData['id'] ?? $newVariantIdMap[$variantData['name']] ?? null;
if ($variantId) {
$variant = $product->productVariants()->findOrFail($variantId);
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']);
$variant->update([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'reject_stock' => $variantData['reject_stock'],
'retail_stock' => $variantData['retail_stock'],
]);
$this->stockMutationService->recordAdjustment($variant, $oldData, $variantData, 'Penyesuaian stok saat edit varian');
} else {
$variant = $product->productVariants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'reject_stock' => $variantData['reject_stock'],
'retail_stock' => $variantData['retail_stock'],
]);
$this->stockMutationService->recordInitial($variant, $variantData, 'Stok awal saat pembuatan varian');
$variantIdLookup[$variantData['name']] = $variantId;
}
}
// Upsert all prices (1 query)
$priceRows = [];
foreach ($data['variants'] as $variantData) {
$variantId = $variantData['id'] ?? $newVariantIdMap[$variantData['name']] ?? null;
if (! $variantId) {
continue;
}
$variant->productPrices()->delete();
$prices = $useSamePrice
? $data['shared_prices']
: $variantData['prices'];
$prices = $useSamePrice ? $data['shared_prices'] : $variantData['prices'];
foreach ($prices as $priceData) {
ProductPrice::create([
'variant_id' => $variant->id,
$priceRows[] = [
'variant_id' => $variantId,
'type' => $priceData['type'],
'price' => $priceData['price'],
]);
'created_at' => $now,
'updated_at' => $now,
];
}
}
if ($priceRows !== []) {
DB::table('product_prices')->upsert(
$priceRows,
['variant_id', 'type'],
['price', 'updated_at']
);
}
// Bulk record stock mutations for new variants
if ($newVariantIdMap !== []) {
$newStockDataMap = [];
foreach ($newVariantsData as $variantData) {
$variantId = $newVariantIdMap[$variantData['name']] ?? null;
if ($variantId) {
$newStockDataMap[$variantId] = $variantData;
}
}
if (! empty($variantData['photo_keys']) && is_array($variantData['photo_keys'])) {
$newVariantModels = ProductVariant::whereIn('id', array_values($newVariantIdMap))->get();
$this->stockMutationService->recordBulkInitial($newVariantModels, $newStockDataMap, 'Stok awal saat pembuatan varian');
}
// Bulk record stock mutations for changed existing variants
if ($changedIds !== []) {
$newDataForExisting = [];
foreach ($data['variants'] as $variantData) {
$variantId = $variantData['id'] ?? null;
if ($variantId && in_array($variantId, $changedIds)) {
$newDataForExisting[$variantId] = $variantData;
}
}
$changedModels = collect($changedIds)->map(fn ($id) => $existingVariantsMap[$id])->filter();
$this->stockMutationService->recordBulkAdjustment(
$changedModels,
$oldStockDataMap,
$newDataForExisting,
'Penyesuaian stok saat edit varian'
);
}
// Register photos (per-variant, S3 operation) - only if changed
foreach ($data['variants'] as $variantData) {
if (empty($variantData['photo_keys']) || ! is_array($variantData['photo_keys'])) {
continue;
}
$variantId = $variantData['id'] ?? $newVariantIdMap[$variantData['name']] ?? null;
if (! $variantId) {
continue;
}
$variant = $existingVariantsMap[$variantId] ?? ProductVariant::find($variantId);
if (! $variant) {
continue;
}
// Compare existing photo keys vs new ones
$existingKeys = $variant->getMedia('photos')->pluck('file_name')->sort()->values()->toArray();
$newKeys = collect($variantData['photo_keys'])->sort()->values()->toArray();
if ($existingKeys !== $newKeys) {
$variant->clearMediaCollection('photos');
$this->variantService->registerPhotos($variant, $variantData['photo_keys']);
}

View File

@ -3,7 +3,6 @@
namespace App\Services\Admin\Master\Product;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService;
@ -23,7 +22,10 @@ public function __construct(
public function getForEdit(ProductVariant $variant): array
{
$variant->load('productPrices');
$variant->load([
'productPrices',
'media',
]);
$media = $variant->getMedia('photos');
$photoKeys = $media->pluck('file_name')->toArray();
@ -59,15 +61,20 @@ public function update(ProductVariant $variant, array $data): ProductVariant
$this->stockMutationService->recordAdjustment($variant, $oldData, $data, 'Penyesuaian stok saat edit varian');
$variant->productPrices()->delete();
// 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();
foreach ($data['prices'] as $priceData) {
ProductPrice::create([
'variant_id' => $variant->id,
'type' => $priceData['type'],
'price' => $priceData['price'],
]);
}
DB::table('product_prices')->upsert(
$priceRows,
['variant_id', 'type'],
['price', 'updated_at']
);
if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) {
$variant->clearMediaCollection('photos');

View File

@ -2,9 +2,12 @@
namespace App\Services;
use App\Models\ProductVariant;
use App\Models\StockMutation;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class StockMutationService
{
@ -61,6 +64,77 @@ public function recordAdjustment(Model $model, array $oldData, array $newData, s
}
}
public function recordBulkInitial(Collection $variants, array $stockDataMap, string $description = 'Stok awal'): void
{
$userId = auth()->id();
$now = now();
$mutations = [];
foreach ($variants as $variant) {
$stockData = $stockDataMap[$variant->id] ?? [];
foreach (self::QUALITY_MAP as $field => $quality) {
$quantity = (int) ($stockData[$field] ?? 0);
if ($quantity > 0) {
$mutations[] = [
'user_id' => $userId,
'stockable_type' => ProductVariant::class,
'stockable_id' => $variant->id,
'type' => 'in',
'quantity' => $quantity,
'stock_before' => 0,
'stock_after' => $quantity,
'stock_quality' => $quality,
'description' => $description,
'created_at' => $now,
'updated_at' => $now,
];
}
}
}
if ($mutations !== []) {
DB::table('stock_mutations')->insert($mutations);
}
}
public function recordBulkAdjustment(Collection $variants, array $oldDataMap, array $newDataMap, string $description = 'Penyesuaian stok'): void
{
$userId = auth()->id();
$now = now();
$mutations = [];
foreach ($variants as $variant) {
$oldData = $oldDataMap[$variant->id] ?? [];
$newData = $newDataMap[$variant->id] ?? [];
foreach (self::QUALITY_MAP as $field => $quality) {
$old = (int) ($oldData[$field] ?? 0);
$new = (int) ($newData[$field] ?? 0);
$diff = $new - $old;
if ($diff !== 0) {
$mutations[] = [
'user_id' => $userId,
'stockable_type' => ProductVariant::class,
'stockable_id' => $variant->id,
'type' => $diff > 0 ? 'in' : 'out',
'quantity' => $diff,
'stock_before' => $old,
'stock_after' => $new,
'stock_quality' => $quality,
'description' => $description,
'created_at' => $now,
'updated_at' => $now,
];
}
}
}
if ($mutations !== []) {
DB::table('stock_mutations')->insert($mutations);
}
}
public function recordTransfer(
Model $model,
int $quantity,