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(); $action();
Inertia::flash('toast', ['type' => 'success', 'message' => $successMessage]); Inertia::flash('toast', ['type' => 'success', 'message' => $successMessage]);
return to_route($redirectRoute, $parameters); return to_route($redirectRoute);
} catch (ValidationException $e) { } catch (ValidationException $e) {
$firstError = collect($e->errors())->flatten()->first(); $firstError = collect($e->errors())->flatten()->first();
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']); Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);

View File

@ -3,7 +3,6 @@
namespace App\Services\Admin\Master\Product; namespace App\Services\Admin\Master\Product;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
@ -90,31 +89,60 @@ public function create(array $data): Product
$useSamePrice = $data['use_same_price'] ?? false; $useSamePrice = $data['use_same_price'] ?? false;
foreach ($data['variants'] as $index => $variantData) { // Bulk insert variants
$variant = $product->productVariants()->create([ $now = now();
'name' => $variantData['name'], $variantRows = collect($data['variants'])->map(fn ($v) => [
'stock' => $variantData['stock'], 'product_id' => $product->id,
'reject_stock' => $variantData['reject_stock'], 'name' => $v['name'],
'retail_stock' => $variantData['retail_stock'], 'stock' => $v['stock'],
]); 'reject_stock' => $v['reject_stock'],
'retail_stock' => $v['retail_stock'],
'created_at' => $now,
'updated_at' => $now,
])->toArray();
$prices = $useSamePrice DB::table('product_variants')->insert($variantRows);
? $data['shared_prices']
: $variantData['prices']; // 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) { foreach ($prices as $priceData) {
ProductPrice::create([ $priceRows[] = [
'variant_id' => $variant->id, 'variant_id' => $variantId,
'type' => $priceData['type'], 'type' => $priceData['type'],
'price' => $priceData['price'], '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'])) { if (! empty($variantData['photo_keys']) && is_array($variantData['photo_keys'])) {
$variant = $insertedVariants->firstWhere('name', $variantData['name']);
$this->variantService->registerPhotos($variant, $variantData['photo_keys']); $this->variantService->registerPhotos($variant, $variantData['photo_keys']);
} }
$this->stockMutationService->recordInitial($variant, $variantData, 'Stok awal saat pembuatan varian');
} }
return $product; return $product;
@ -180,12 +208,15 @@ public function update(Product $product, array $data): Product
$product->categories()->sync($data['category_ids']); $product->categories()->sync($data['category_ids']);
$useSamePrice = $data['use_same_price'] ?? false; $useSamePrice = $data['use_same_price'] ?? false;
$now = now();
// Identify existing vs new variants
$existingVariantIds = collect($data['variants']) $existingVariantIds = collect($data['variants'])
->pluck('id') ->pluck('id')
->filter() ->filter()
->toArray(); ->toArray();
// Delete removed variants (cascade prices + media)
$product->productVariants() $product->productVariants()
->whereNotIn('id', $existingVariantIds) ->whereNotIn('id', $existingVariantIds)
->each(function (ProductVariant $variant) { ->each(function (ProductVariant $variant) {
@ -194,44 +225,162 @@ public function update(Product $product, array $data): Product
$variant->delete(); $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) { foreach ($data['variants'] as $variantData) {
$variantId = $variantData['id'] ?? null; $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) { if ($variantId) {
$variant = $product->productVariants()->findOrFail($variantId); $variantIdLookup[$variantData['name']] = $variantId;
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']); }
$variant->update([ }
'name' => $variantData['name'],
'stock' => $variantData['stock'], // Upsert all prices (1 query)
'reject_stock' => $variantData['reject_stock'], $priceRows = [];
'retail_stock' => $variantData['retail_stock'], foreach ($data['variants'] as $variantData) {
]); $variantId = $variantData['id'] ?? $newVariantIdMap[$variantData['name']] ?? null;
$this->stockMutationService->recordAdjustment($variant, $oldData, $variantData, 'Penyesuaian stok saat edit varian'); if (! $variantId) {
} else { continue;
$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');
} }
$variant->productPrices()->delete(); $prices = $useSamePrice ? $data['shared_prices'] : $variantData['prices'];
$prices = $useSamePrice
? $data['shared_prices']
: $variantData['prices'];
foreach ($prices as $priceData) { foreach ($prices as $priceData) {
ProductPrice::create([ $priceRows[] = [
'variant_id' => $variant->id, 'variant_id' => $variantId,
'type' => $priceData['type'], 'type' => $priceData['type'],
'price' => $priceData['price'], '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'); $variant->clearMediaCollection('photos');
$this->variantService->registerPhotos($variant, $variantData['photo_keys']); $this->variantService->registerPhotos($variant, $variantData['photo_keys']);
} }

View File

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

View File

@ -2,9 +2,12 @@
namespace App\Services; namespace App\Services;
use App\Models\ProductVariant;
use App\Models\StockMutation; use App\Models\StockMutation;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class StockMutationService 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( public function recordTransfer(
Model $model, Model $model,
int $quantity, int $quantity,