From 4a647cb3e6c488bfc1840d6070e056f647ce7ad4 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 1 Jul 2026 17:28:31 +0700 Subject: [PATCH] feat: enhance product variant management by adding price fields and functionality for copying, pasting, and applying prices across variants --- .../Requests/Admin/Master/ProductRequest.php | 18 +++ .../Manage/CuttingResultPriceResolver.php | 53 ++++++++- app/Services/Master/ProductService.php | 49 ++++++++- .../VerificationChangeFormatter.php | 22 +++- resources/js/pages/admin/Analysis.vue | 22 ++-- .../js/pages/admin/master/products/Edit.vue | 1 + .../master/products/form/ProductForm.vue | 83 ++++++++++++-- .../products/form/ProductVariantSection.vue | 103 +++++++++++++++++- resources/js/types/product.ts | 2 + 9 files changed, 316 insertions(+), 37 deletions(-) diff --git a/app/Http/Requests/Admin/Master/ProductRequest.php b/app/Http/Requests/Admin/Master/ProductRequest.php index 85c3b9e..8abedc8 100644 --- a/app/Http/Requests/Admin/Master/ProductRequest.php +++ b/app/Http/Requests/Admin/Master/ProductRequest.php @@ -43,6 +43,15 @@ public function rules(): array 'variants.*.name' => ['required', 'string', 'max:200'], 'variants.*.stock' => ['required', 'integer', 'min:0'], 'variants.*.retail_stock' => ['required', 'integer', 'min:0'], + 'variants.*.prices' => ['required', 'array'], + 'variants.*.prices.distributor' => ['required', 'integer', 'min:0'], + 'variants.*.prices.agent' => ['required', 'integer', 'min:0'], + 'variants.*.prices.sub_agent' => ['required', 'integer', 'min:0'], + 'variants.*.prices.grosir' => ['required', 'integer', 'min:0'], + 'variants.*.prices.retail' => ['required', 'integer', 'min:0'], + 'variants.*.prices.tiktok' => ['required', 'integer', 'min:0'], + 'variants.*.prices.shopee' => ['required', 'integer', 'min:0'], + 'variants.*.prices.harga_modal' => ['required', 'integer', 'min:0'], ...$this->variantImageRules(), ]; } @@ -61,6 +70,15 @@ public function attributes(): array 'variants.*.name' => 'Nama Varian', 'variants.*.stock' => 'Stok', 'variants.*.retail_stock' => 'Stok Ecer', + 'variants.*.prices' => 'Harga', + 'variants.*.prices.distributor' => 'Distributor', + 'variants.*.prices.agent' => 'Agen', + 'variants.*.prices.sub_agent' => 'Sub Agen', + 'variants.*.prices.grosir' => 'Grosir', + 'variants.*.prices.retail' => 'Eceran', + 'variants.*.prices.tiktok' => 'TikTok', + 'variants.*.prices.shopee' => 'Shopee', + 'variants.*.prices.harga_modal' => 'Harga Modal', ...$this->variantImageAttributes('variants', 'Foto Varian'), ]; } diff --git a/app/Services/Manage/CuttingResultPriceResolver.php b/app/Services/Manage/CuttingResultPriceResolver.php index 9434f2d..984ce39 100644 --- a/app/Services/Manage/CuttingResultPriceResolver.php +++ b/app/Services/Manage/CuttingResultPriceResolver.php @@ -4,13 +4,14 @@ use App\Enums\PriceType; use App\Models\CuttingResultPrice; +use App\Models\ProductPrice; use Illuminate\Support\Collection; class CuttingResultPriceResolver { public function resolve(int $productVariantId, PriceType $priceType): ?CuttingResultPrice { - return CuttingResultPrice::query() + $price = CuttingResultPrice::query() ->where('product_variant_id', $productVariantId) ->where('price_type', $priceType) ->whereHas('cutting', fn ($query) => $query->verified()) @@ -18,6 +19,26 @@ public function resolve(int $productVariantId, PriceType $priceType): ?CuttingRe ->orderByDesc('cuttings.created_at') ->select('cutting_result_prices.*') ->first(); + + if ($price !== null) { + return $price; + } + + $productPrice = ProductPrice::query() + ->where('variant_id', $productVariantId) + ->where('type', $priceType) + ->first(); + + if ($productPrice !== null) { + $cp = new CuttingResultPrice; + $cp->product_variant_id = $productVariantId; + $cp->price_type = $priceType; + $cp->price = $productPrice->price; + + return $cp; + } + + return null; } public function latestPricesForVariant(int $productVariantId): array @@ -41,7 +62,7 @@ public function latestPricesForVariants(array $variantIds): Collection return collect(); } - return CuttingResultPrice::query() + $cuttingPrices = CuttingResultPrice::query() ->whereIn('product_variant_id', $variantIds) ->whereHas('cutting', fn ($query) => $query->verified()) ->join('cuttings', 'cutting_result_prices.cutting_id', '=', 'cuttings.id') @@ -49,8 +70,30 @@ public function latestPricesForVariants(array $variantIds): Collection ->select('cutting_result_prices.*') ->get() ->groupBy(fn (CuttingResultPrice $price) => $price->product_variant_id.'-'.$price->price_type->value) - ->map(fn (Collection $group) => $group->first()) - ->values() - ->groupBy('product_variant_id'); + ->map(fn (Collection $group) => $group->first()); + + $productPrices = ProductPrice::query() + ->whereIn('variant_id', $variantIds) + ->get() + ->groupBy(fn (ProductPrice $price) => $price->variant_id.'-'.$price->type->value); + + $results = collect(); + foreach ($variantIds as $variantId) { + foreach (PriceType::cases() as $priceType) { + $key = $variantId.'-'.$priceType->value; + if ($cuttingPrices->has($key)) { + $results->push($cuttingPrices->get($key)); + } elseif ($productPrices->has($key)) { + $pp = $productPrices->get($key)->first(); + $cp = new CuttingResultPrice; + $cp->product_variant_id = $variantId; + $cp->price_type = $priceType; + $cp->price = $pp->price; + $results->push($cp); + } + } + } + + return $results->groupBy('product_variant_id'); } } diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index 14aa1d9..986f55b 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -104,7 +104,7 @@ public function findForEdit(Product $product): Product $product->load([ 'categories', 'variants' => fn ($query) => $query - ->with('media') + ->with(['media', 'prices']) ->orderBy('created_at'), ]); @@ -137,6 +137,13 @@ public function create(array $validated, User $user): void ]); $this->syncVariantImages($variant, $variantData, $index); + + foreach ($variantData['prices'] as $type => $priceValue) { + $variant->prices()->create([ + 'type' => $type, + 'price' => $priceValue, + ]); + } } if (! $isOwner) { @@ -191,6 +198,14 @@ public function update(Product $product, array $validated, User $user): void $variant = $product->variants()->find($variantData['id']); if ($variant) { $this->syncVariantImages($variant, $variantData, $index); + if (! empty($variantData['prices'])) { + foreach ($variantData['prices'] as $type => $priceValue) { + $variant->prices()->updateOrCreate( + ['type' => $type], + ['price' => $priceValue] + ); + } + } } } else { $variant = $product->variants()->create([ @@ -199,6 +214,14 @@ public function update(Product $product, array $validated, User $user): void 'retail_stock' => $variantData['retail_stock'], ]); $this->syncVariantImages($variant, $variantData, $index); + if (! empty($variantData['prices'])) { + foreach ($variantData['prices'] as $type => $priceValue) { + $variant->prices()->create([ + 'type' => $type, + 'price' => $priceValue, + ]); + } + } } } } else { @@ -444,6 +467,15 @@ private function applyPayloadToProduct( $this->applyVariantImageChanges($verificationRequest, $variant, $variantData, (int) $index); } + if (! empty($variantData['prices'])) { + foreach ($variantData['prices'] as $type => $priceValue) { + $variant->prices()->updateOrCreate( + ['type' => $type], + ['price' => $priceValue] + ); + } + } + continue; } @@ -456,6 +488,15 @@ private function applyPayloadToProduct( if ($verificationRequest !== null) { $this->copyRequestVariantImages($verificationRequest, (int) $index, $variant); } + + if (! empty($variantData['prices'])) { + foreach ($variantData['prices'] as $type => $priceValue) { + $variant->prices()->create([ + 'type' => $type, + 'price' => $priceValue, + ]); + } + } } } @@ -584,7 +625,7 @@ private function payloadNew(OwnerVerificationRequest $verificationRequest): arra private function snapshotProduct(Product $product): array { - $product->load(['categories', 'variants']); + $product->load(['categories', 'variants.prices']); return $this->enrichPayload([ 'name' => $product->name, @@ -597,6 +638,9 @@ private function snapshotProduct(Product $product): array 'name' => $variant->name, 'stock' => $variant->stock, 'retail_stock' => $variant->retail_stock, + 'prices' => $variant->prices + ->mapWithKeys(fn ($price) => [$price->type->value => $price->price]) + ->all(), ]) ->all(), ]); @@ -625,6 +669,7 @@ private function buildPayloadFromValidated(array $validated): array 'name' => $variantData['name'], 'stock' => $variantData['stock'], 'retail_stock' => $variantData['retail_stock'], + 'prices' => $variantData['prices'] ?? [], 'remove_media_ids' => $variantData['remove_media_ids'] ?? [], ]) ->all(), diff --git a/app/Support/OwnerVerification/VerificationChangeFormatter.php b/app/Support/OwnerVerification/VerificationChangeFormatter.php index 48ea3a9..94f84fc 100644 --- a/app/Support/OwnerVerification/VerificationChangeFormatter.php +++ b/app/Support/OwnerVerification/VerificationChangeFormatter.php @@ -2,6 +2,8 @@ namespace App\Support\OwnerVerification; +use App\Enums\PriceType; + class VerificationChangeFormatter { /** @@ -144,10 +146,22 @@ private static function presentValue(string $field, mixed $value): mixed if ($field === 'variants' && is_array($value)) { return collect($value) - ->map(fn (array $variant) => [ - 'name' => $variant['name'] ?? '-', - 'stock' => (int) ($variant['stock'] ?? 0), - ]) + ->map(function (array $variant) { + $pricesStr = ''; + if (! empty($variant['prices'])) { + $priceParts = []; + foreach ($variant['prices'] as $type => $price) { + $label = PriceType::tryFrom($type)?->label() ?? $type; + $priceParts[] = "{$label}: Rp ".number_format((int) $price, 0, ',', '.'); + } + $pricesStr = ' ('.implode(', ', $priceParts).')'; + } + + return [ + 'name' => ($variant['name'] ?? '-').$pricesStr, + 'stock' => (int) ($variant['stock'] ?? 0), + ]; + }) ->values() ->all(); } diff --git a/resources/js/pages/admin/Analysis.vue b/resources/js/pages/admin/Analysis.vue index b6788ad..b1a4d3b 100644 --- a/resources/js/pages/admin/Analysis.vue +++ b/resources/js/pages/admin/Analysis.vue @@ -1,6 +1,6 @@