feat: optimize product variant handling with bulk insertions and adjustments; enhance stock mutation recording
This commit is contained in:
parent
44dab8271f
commit
549e3e3bca
@ -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.']);
|
||||||
|
|||||||
@ -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']);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user