|
|
@@ -4,12 +4,21 @@ namespace Longyi\Core\Http\Controllers\Admin;
|
|
|
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
use Illuminate\Http\Request;
|
|
|
+use Illuminate\Http\UploadedFile;
|
|
|
use Illuminate\Routing\Controller;
|
|
|
use Illuminate\Foundation\Validation\ValidatesRequests;
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
+use Illuminate\Support\Facades\Storage;
|
|
|
+use Illuminate\Support\Str;
|
|
|
+use Intervention\Image\ImageManager;
|
|
|
+use Longyi\Core\Helpers\FlexibleVariantOption;
|
|
|
+use Longyi\Core\Models\ProductOption;
|
|
|
+use Longyi\Core\Models\ProductOptionValue;
|
|
|
+use Longyi\Core\Models\ProductVariant;
|
|
|
use Longyi\Core\Repositories\ProductOptionRepository;
|
|
|
use Longyi\Core\Repositories\ProductOptionValueRepository;
|
|
|
use Longyi\Core\Repositories\ProductVariantRepository;
|
|
|
-use Longyi\Core\Helpers\FlexibleVariantOption;
|
|
|
+use Webkul\Product\Helpers\Indexers\Price as PriceIndexer;
|
|
|
use Webkul\Product\Repositories\ProductRepository;
|
|
|
|
|
|
class FlexibleVariantController extends Controller
|
|
|
@@ -41,6 +50,12 @@ class FlexibleVariantController extends Controller
|
|
|
*/
|
|
|
protected $flexibleVariantHelper;
|
|
|
|
|
|
+
|
|
|
+ public $_product = null;
|
|
|
+
|
|
|
+
|
|
|
+ public $_selectedOptions = [];
|
|
|
+
|
|
|
/**
|
|
|
* Create a new controller instance.
|
|
|
*/
|
|
|
@@ -56,6 +71,19 @@ class FlexibleVariantController extends Controller
|
|
|
$this->productVariantRepository = $productVariantRepository;
|
|
|
$this->productRepository = $productRepository;
|
|
|
$this->flexibleVariantHelper = $flexibleVariantHelper;
|
|
|
+ //根据请求初始化产品信息
|
|
|
+ // $this->initProduct(request()->product_id ?? null);
|
|
|
+
|
|
|
+
|
|
|
+ }
|
|
|
+ public function initProduct(int $productId){
|
|
|
+ if(!$productId){
|
|
|
+ $productId = request()->route('productId');
|
|
|
+ }
|
|
|
+ $this->_product = $this->productRepository->find($productId);
|
|
|
+ if (!$this->_product) {
|
|
|
+ throw new \Exception('Product not found');
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// ==================== OPTION MANAGEMENT ====================
|
|
|
@@ -99,8 +127,9 @@ class FlexibleVariantController extends Controller
|
|
|
public function createOption(Request $request): JsonResponse
|
|
|
{
|
|
|
$this->validate($request, [
|
|
|
+ 'product_id' => 'required|exists:products,id',
|
|
|
'label' => 'required|string|max:255',
|
|
|
- 'code' => 'required|string|max:100|unique:product_options,code',
|
|
|
+ 'code' => 'required|string|max:100',
|
|
|
'type' => 'required|in:select,radio,checkbox,color,button',
|
|
|
'position' => 'nullable|integer',
|
|
|
'meta' => 'nullable|array',
|
|
|
@@ -110,8 +139,20 @@ class FlexibleVariantController extends Controller
|
|
|
'values.*.position' => 'nullable|integer',
|
|
|
'values.*.meta' => 'nullable|array',
|
|
|
]);
|
|
|
-
|
|
|
- $option = $this->productOptionRepository->createWithValues($request->all());
|
|
|
+ $product = $this->productRepository->find($request->product_id);
|
|
|
+ if (!$product) {
|
|
|
+ return response()->json([
|
|
|
+ 'success' => false,
|
|
|
+ 'message' => 'Product not found',
|
|
|
+ ], 404);
|
|
|
+ }
|
|
|
+ $option = $this->productOptionRepository->createWithValues($request->except('product_id'));
|
|
|
+
|
|
|
+ $product->options()->attach($option->id, [
|
|
|
+ 'position' => $request->position ?? 0,
|
|
|
+ 'is_required' => $request->is_required ?? false,
|
|
|
+ 'meta' => isset($request->meta) ? json_encode($request->meta) : null,
|
|
|
+ ]);
|
|
|
|
|
|
return response()->json([
|
|
|
'success' => true,
|
|
|
@@ -176,7 +217,18 @@ class FlexibleVariantController extends Controller
|
|
|
*/
|
|
|
public function getProductOptions(int $productId): JsonResponse
|
|
|
{
|
|
|
- $options = $this->flexibleVariantHelper->getProductOptions($productId);
|
|
|
+
|
|
|
+
|
|
|
+ $options=$this->flexibleVariantHelper->getAvailableOptions($productId);
|
|
|
+ // $this->initProduct($productId);
|
|
|
+ // $options = $this->_product->options()
|
|
|
+ // ->with('values', function ($query) {
|
|
|
+ // $query->whereHas('variants', function ($relation) {
|
|
|
+ // // dd($relation->getModel()->getTable());exit;
|
|
|
+ // $relation->whereIn($relation->getModel()->getTable().'.id', $this->_product->variants()->pluck('id'));
|
|
|
+ // });
|
|
|
+ // })->toSql();
|
|
|
+
|
|
|
|
|
|
return response()->json([
|
|
|
'success' => true,
|
|
|
@@ -190,6 +242,7 @@ class FlexibleVariantController extends Controller
|
|
|
public function attachOptions(Request $request, int $productId): JsonResponse
|
|
|
{
|
|
|
$this->validate($request, [
|
|
|
+ 'product_id' => 'required|exists:products,id',
|
|
|
'options' => 'required|array',
|
|
|
'options.*.id' => 'required|exists:product_options,id',
|
|
|
'options.*.position' => 'nullable|integer',
|
|
|
@@ -237,13 +290,42 @@ class FlexibleVariantController extends Controller
|
|
|
'data' => $variants,
|
|
|
]);
|
|
|
}
|
|
|
+ public function storeSelectedOptions(){
|
|
|
+ foreach ($this->_selectedOptions as $optionIndex => $option) {
|
|
|
+ $optionModel = empty($option['id']) ? new ProductOption() : ProductOption::findOrFail($option['id']);
|
|
|
+
|
|
|
+ $mapOptionValues = $optionModel->values()->pluck('id','code')->toArray();
|
|
|
+
|
|
|
+ $optionModel->label = $option['label'];
|
|
|
+ $optionModel->type = $option['type'];
|
|
|
+ $optionModel->code = $option['code'];
|
|
|
+ $optionModel->position = $option['position'];
|
|
|
+ $optionModel->save();
|
|
|
+
|
|
|
+ $this->_selectedOptions[$optionIndex]['id'] = $optionModel->id;
|
|
|
+ $option['id'] = $optionModel->id;
|
|
|
+
|
|
|
+ foreach ($option['option_values'] as $optionValueIndex => $value) {
|
|
|
+ $optionValueModel = ProductOptionValue::find(($value['id'] ?? null) ?: ($mapOptionValues[$value['code']] ?? null))
|
|
|
+ ?? new ProductOptionValue(['product_option_id' => $option['id']]);
|
|
|
+
|
|
|
+ $optionValueModel->label = $value['label'];
|
|
|
+ $optionValueModel->code = $value['code'];
|
|
|
+ $optionValueModel->position = $value['position'];
|
|
|
+ $optionValueModel->save();
|
|
|
+
|
|
|
+ $this->_selectedOptions[$optionIndex]['option_values'][$optionValueIndex]['id'] =
|
|
|
+ $optionValueModel->id;
|
|
|
+ }
|
|
|
|
|
|
+ }
|
|
|
+ }
|
|
|
/**
|
|
|
* Get single variant
|
|
|
*/
|
|
|
public function getVariant(int $id): JsonResponse
|
|
|
{
|
|
|
- $variant = $this->productVariantRepository->with('optionValues.option')->find($id);
|
|
|
+ $variant = $this->productVariantRepository->with('values.option')->find($id);
|
|
|
|
|
|
if (!$variant) {
|
|
|
return response()->json([
|
|
|
@@ -257,7 +339,171 @@ class FlexibleVariantController extends Controller
|
|
|
'data' => $variant,
|
|
|
]);
|
|
|
}
|
|
|
+ public function saveVariants(int $productId,Request $request){
|
|
|
+ $this->initProduct($productId);
|
|
|
+ $this->validate($request,[
|
|
|
+ 'selected_options' => 'array',
|
|
|
+ 'selected_options.*.code' => 'required|string',
|
|
|
+ 'selected_options.*.option_values.*.code' => 'required|string',
|
|
|
+ 'variants' => 'array',
|
|
|
+ 'variants.*.sku' => 'required|string',
|
|
|
+ 'variants.*.quantity' => 'required|integer',
|
|
|
+ 'variants.*.values' => 'array',
|
|
|
+ 'variants.*.values.*.code' => 'required|string',
|
|
|
+ 'variants.*.image_ids' => 'array',
|
|
|
+ 'variants.*.image_ids.*' => 'integer|exists:product_images,id',
|
|
|
+ ]);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ DB::beginTransaction();
|
|
|
+ $this->_selectedOptions = $request->selected_options;
|
|
|
+
|
|
|
+ //存储选项
|
|
|
+ $this->storeSelectedOptions();
|
|
|
+
|
|
|
+ $variants=$request->variants;
|
|
|
+ //如果无变体,则删除所有变体和选项
|
|
|
+ if (!count($variants)) {
|
|
|
+ $variant=$this->_product->variants()->first();
|
|
|
+ $variant->values()->detach();
|
|
|
+
|
|
|
+ //删除产品选项
|
|
|
+ $this->_product->options()->delete();
|
|
|
+ $this->_product->flexibleVariants()
|
|
|
+ ->where('id', '!=', $variant->id)
|
|
|
+ ->get()
|
|
|
+ ->each(
|
|
|
+ fn ($variant) => $variant->delete()
|
|
|
+ );
|
|
|
+ DB::commit();
|
|
|
+ return response()->json([
|
|
|
+ 'success' => true,
|
|
|
+ 'message' => 'Variants deleted successfully',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ }
|
|
|
+ //存储变体
|
|
|
+ foreach ($variants as $variantIndex => $variantData) {
|
|
|
+ if (! empty($variantData['id'])) {
|
|
|
+ $variant = ProductVariant::find($variantData['id']);
|
|
|
+ } elseif (! empty($variantData['copied_id'])) {
|
|
|
+ $copiedVariant = ProductVariant::find($variantData['copied_id']);
|
|
|
+ $variant = $copiedVariant->replicate();
|
|
|
+ $variant->save();
|
|
|
+ } else {
|
|
|
+ $variant = ProductVariant::onlyTrashed()
|
|
|
+ ->where('product_id', $this->_product->id)
|
|
|
+ ->where('sku', $variantData['sku'])
|
|
|
+ ->first();
|
|
|
+
|
|
|
+ if ($variant) {
|
|
|
+ $variant->restore();
|
|
|
+ } else {
|
|
|
+ $variant = new ProductVariant([
|
|
|
+ 'product_id' => $this->_product->id,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $variant->sku = $variantData['sku'];
|
|
|
+ $variant->quantity = $variantData['quantity'];
|
|
|
+ $variant->price = $variantData['price'];
|
|
|
+ $variant->save();
|
|
|
+
|
|
|
+ $this->syncVariantBasePrices($variant, (float) $variantData['price']);
|
|
|
+
|
|
|
+ $optionsValues = $this->mapOptionValuesToIds($variantData['values']);
|
|
|
+
|
|
|
+ $variant->values()->sync($optionsValues);
|
|
|
+
|
|
|
+ if (isset($variantData['image_ids'])) {
|
|
|
+ $imageSync = collect($variantData['image_ids'])
|
|
|
+ ->values()
|
|
|
+ ->mapWithKeys(fn ($imageId, $i) => [$imageId => ['position' => $i]]);
|
|
|
+ $variant->images()->sync($imageSync);
|
|
|
+ }
|
|
|
+
|
|
|
+ $variants[$variantIndex]['variant_id'] = $variant->id;
|
|
|
+ }
|
|
|
+
|
|
|
+ $productOptions = collect($this->_selectedOptions)
|
|
|
+ ->mapWithKeys(function ($option) use ($variant) {
|
|
|
+ return [
|
|
|
+ $option['id'] => [
|
|
|
+ 'position' => $option['position'],
|
|
|
+ ],
|
|
|
+ ];
|
|
|
+ });
|
|
|
+ //同步选项
|
|
|
+ $this->_product->options()->sync($productOptions);
|
|
|
+
|
|
|
+ $variantIds=collect($variants)->pluck('id');
|
|
|
+ //删除无用的变体
|
|
|
+ $this->_product->flexibleVariants()->whereNotIn('id', $variantIds)->delete();
|
|
|
+ DB::commit();
|
|
|
+
|
|
|
+ $this->reindexProduct($productId);
|
|
|
+ return response()->json([
|
|
|
+ 'success' => true,
|
|
|
+ 'message' => 'Variants saved successfully',
|
|
|
+ 'data' => [
|
|
|
+ 'variants' => $variants,
|
|
|
+ 'selected_options' => $this->_selectedOptions,
|
|
|
+ ],
|
|
|
+ ]);
|
|
|
+
|
|
|
+
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * Create or update base price indices (customer_group_id = NULL) for a variant across all channels.
|
|
|
+ */
|
|
|
+ protected function syncVariantBasePrices(ProductVariant $variant, float $price): void
|
|
|
+ {
|
|
|
+ foreach (core()->getAllChannels() as $channel) {
|
|
|
+ $basePrice = $variant->basePrices()
|
|
|
+ ->where('channel_id', $channel->id)
|
|
|
+ ->first();
|
|
|
+
|
|
|
+ $priceData = [
|
|
|
+ 'min_price' => $price,
|
|
|
+ 'regular_min_price' => $price,
|
|
|
+ 'max_price' => $price,
|
|
|
+ 'regular_max_price' => $price,
|
|
|
+ ];
|
|
|
+
|
|
|
+ if ($basePrice) {
|
|
|
+ $basePrice->update($priceData);
|
|
|
+ } else {
|
|
|
+ $variant->price_indices()->create(array_merge($priceData, [
|
|
|
+ 'channel_id' => $channel->id,
|
|
|
+ 'customer_group_id' => null,
|
|
|
+ ]));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public function mapOptionValuesToIds(array $values){
|
|
|
+ $valueIds = [];
|
|
|
+ foreach ($values as $optionCode => $value) {
|
|
|
+ $selectedOption = collect($this->_selectedOptions)
|
|
|
+ ->first(fn ($o) => $o['code'] == $optionCode);
|
|
|
+
|
|
|
+ if (! $selectedOption) {
|
|
|
+ throw new \InvalidArgumentException("Option [{$optionCode}] not found in selected options.");
|
|
|
+ }
|
|
|
+
|
|
|
+ $optionValue = collect($selectedOption['option_values'])
|
|
|
+ ->first(fn ($v) => $v['code'] == $value['code']);
|
|
|
+
|
|
|
+ if (! $optionValue) {
|
|
|
+ throw new \InvalidArgumentException("Option value [{$value['code']}] not found in option [{$optionCode}].");
|
|
|
+ }
|
|
|
|
|
|
+ $valueIds[] = $optionValue['id'];
|
|
|
+ }
|
|
|
+ return $valueIds;
|
|
|
+ }
|
|
|
/**
|
|
|
* Create new variant
|
|
|
*/
|
|
|
@@ -345,8 +591,12 @@ class FlexibleVariantController extends Controller
|
|
|
], 404);
|
|
|
}
|
|
|
|
|
|
+ $productId = $variant->product_id;
|
|
|
+
|
|
|
$this->productVariantRepository->delete($id);
|
|
|
|
|
|
+ $this->reindexProduct($productId);
|
|
|
+
|
|
|
return response()->json([
|
|
|
'success' => true,
|
|
|
'message' => 'Variant deleted successfully',
|
|
|
@@ -387,10 +637,18 @@ class FlexibleVariantController extends Controller
|
|
|
'status' => 'required|boolean',
|
|
|
]);
|
|
|
|
|
|
+ $productIds = [];
|
|
|
+
|
|
|
foreach ($request->variant_ids as $variantId) {
|
|
|
- $this->productVariantRepository->update([
|
|
|
+ $variant = $this->productVariantRepository->update([
|
|
|
'status' => $request->status,
|
|
|
], $variantId);
|
|
|
+
|
|
|
+ $productIds[$variant->product_id] = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach (array_keys($productIds) as $productId) {
|
|
|
+ $this->reindexProduct($productId);
|
|
|
}
|
|
|
|
|
|
return response()->json([
|
|
|
@@ -398,4 +656,92 @@ class FlexibleVariantController extends Controller
|
|
|
'message' => 'Status updated successfully',
|
|
|
]);
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Sync images for a variant.
|
|
|
+ *
|
|
|
+ * Accepts a mix of existing product_image IDs and new file uploads.
|
|
|
+ * New files are stored into the product gallery first, then all IDs
|
|
|
+ * are synced to the variant via the pivot table.
|
|
|
+ *
|
|
|
+ * POST /variants/{id}/images
|
|
|
+ * Body (multipart/form-data):
|
|
|
+ * image_ids[] - existing product_image IDs to keep/attach
|
|
|
+ * uploads[] - new image files to upload
|
|
|
+ */
|
|
|
+ public function syncVariantImages(int $id, Request $request): JsonResponse
|
|
|
+ {
|
|
|
+ $this->validate($request, [
|
|
|
+ 'image_ids' => 'array',
|
|
|
+ 'image_ids.*' => 'integer|exists:product_images,id',
|
|
|
+ 'uploads' => 'array',
|
|
|
+ 'uploads.*' => 'image|max:5120',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $variant = ProductVariant::findOrFail($id);
|
|
|
+ $product = $variant->product;
|
|
|
+
|
|
|
+ $imageIds = collect($request->input('image_ids', []));
|
|
|
+
|
|
|
+ if ($request->hasFile('uploads')) {
|
|
|
+ foreach ($request->file('uploads') as $file) {
|
|
|
+ $newImage = $this->storeProductImage($file, $product);
|
|
|
+ $imageIds->push($newImage->id);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $syncData = $imageIds->values()->mapWithKeys(
|
|
|
+ fn ($imageId, $i) => [$imageId => ['position' => $i]]
|
|
|
+ );
|
|
|
+
|
|
|
+ $variant->images()->sync($syncData);
|
|
|
+
|
|
|
+ return response()->json([
|
|
|
+ 'success' => true,
|
|
|
+ 'data' => $variant->load('images')->images,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Store an uploaded file into the product gallery (product_images table).
|
|
|
+ */
|
|
|
+ protected function storeProductImage(UploadedFile $file, $product): \Webkul\Product\Models\ProductImage
|
|
|
+ {
|
|
|
+ $directory = 'product/' . $product->id;
|
|
|
+
|
|
|
+ if (Str::contains($file->getMimeType(), 'image')) {
|
|
|
+ $manager = new ImageManager;
|
|
|
+ $image = $manager->make($file)->encode('webp');
|
|
|
+ $path = $directory . '/' . Str::random(40) . '.webp';
|
|
|
+ Storage::put($path, $image);
|
|
|
+ } else {
|
|
|
+ $path = $file->store($directory);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $product->images()->create([
|
|
|
+ 'type' => 'images',
|
|
|
+ 'path' => $path,
|
|
|
+ 'position' => $product->images()->count(),
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Trigger price reindex for a product and its flexible variants.
|
|
|
+ */
|
|
|
+ protected function reindexProduct(int $productId): void
|
|
|
+ {
|
|
|
+ $product = $this->productRepository->with([
|
|
|
+ 'attribute_family',
|
|
|
+ 'attribute_values',
|
|
|
+ 'price_indices',
|
|
|
+ 'customer_group_prices',
|
|
|
+ 'flexibleVariants',
|
|
|
+ 'flexibleVariants.price_indices',
|
|
|
+ 'flexibleVariants.customer_group_prices',
|
|
|
+ ])->find($productId);
|
|
|
+
|
|
|
+ if ($product) {
|
|
|
+ app(PriceIndexer::class)->reindexBatch([$product]);
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|