# fluent-cart/1.6.4/app/Services/BulkProductUpdateService.php

FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler, version 1.6.4. 652 lines.

- Page: https://pluginprobe.com/plugins/fluent-cart/1.6.4/code/app/Services/BulkProductUpdateService.php
- Raw: https://pluginprobe.com/plugins/fluent-cart/1.6.4/raw/app/Services/BulkProductUpdateService.php
- Modified: 2026-09-11T13:28:26+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/fluent-cart/1.6.4/code/app/Services/BulkProductUpdateService.php#L10-L20`.

```php
<?php

namespace FluentCart\App\Services;

use FluentCart\Api\Resource\ProductResource;
use FluentCart\Api\Resource\ProductVariationResource;
use FluentCart\App\Helpers\Helper;
use FluentCart\App\Models\Product;
use FluentCart\App\Models\ProductDetail;
use FluentCart\App\Models\ProductVariation;
use FluentCart\App\Services\Filter\ProductFilter;
use FluentCart\Framework\Http\Request\Request;
use FluentCart\Framework\Support\Arr;
use FluentCart\App\Http\Rules\RequiredWhenRule;
use FluentCart\App\Http\Rules\WhenFilledRule;
use FluentCart\Framework\Validator\Validator;

class BulkProductUpdateService
{
    /**
     * Fetch products formatted for bulk editing.
     * Returns money in CENTS and category terms. It said "decimal prices" back
     * when a temporary adapter divided here for a dollars-based grid; the grid
     * renders cents through PriceInput now, so nothing is scaled on the way out.
     */
    public function fetchForBulkEdit(Request $request): array
    {
        $products = ProductFilter::fromRequest($request)->paginate();

        $formatted = $products->getCollection()->map(function ($product) {
            return $this->formatProductForEdit($product);
        });

        return [
            'products' => $formatted->values()->toArray(),
            'total'    => $products->total(),
            'per_page' => $products->perPage(),
            'page'     => $products->currentPage(),
        ];
    }

    /**
     * Format a single product for the bulk edit spreadsheet.
     * Money stays in cents; PriceInput renders it as dollars in the grid.
     */
    protected function formatProductForEdit(Product $product): array
    {
        $product->load([
            'detail',
            'variants' => function ($query) {
                $query->orderBy('serial_index', 'ASC');
            },
            'variants.media',
        ]);

        $data = [
            'ID'           => $product->ID,
            'post_title'   => $product->post_title,
            'post_content' => $product->post_content,
            'post_excerpt' => $product->post_excerpt,
            'post_status'  => $product->post_status,
            'view_url'     => get_permalink($product->ID),
        ];

        // Gallery images
        $gallery = get_post_meta($product->ID, 'fluent-products-gallery-image', true);
        $data['gallery'] = (!empty($gallery) && is_array($gallery)) ? $gallery : [];

        // Detail
        if ($product->detail) {
            $data['detail'] = [
                'variation_type'   => $product->detail->variation_type,
                'fulfillment_type' => $product->detail->fulfillment_type,
                'manage_stock'     => (int) $product->detail->manage_stock,
            ];
        }

        // Variants — money stays in cents
        $data['variants'] = [];
        if ($product->variants) {
            foreach ($product->variants as $variant) {
                $variantMedia = [];
                if ($variant->media && is_array($variant->media->meta_value)) {
                    $variantMedia = $variant->media->meta_value;
                }

                $variantData = [
                    'id'              => $variant->id,
                    'post_id'         => $variant->post_id,
                    'variation_title' => $variant->variation_title,
                    'sku'             => $variant->sku,
                    // Cents, as stored and as the write endpoints now expect.
                    // PriceInput renders these as dollars for the merchant.
                    'item_price'      => (int) $variant->item_price,
                    'compare_price'   => (int) $variant->compare_price,
                    'payment_type'    => $variant->payment_type,
                    'manage_stock'    => (int) $variant->manage_stock,
                    'total_stock'     => (int) $variant->total_stock,
                    'available'       => (int) $variant->available,
                    'stock_status'    => $variant->stock_status,
                    'serial_index'    => (int) $variant->serial_index,
                    'fulfillment_type' => $variant->fulfillment_type,
                    'other_info'      => $this->formatOtherInfoForEdit($variant->other_info ?? []),
                    'media'           => $variantMedia,
                ];
                $data['variants'][] = $variantData;
            }
        }

        // Categories
        $terms = get_the_terms($product->ID, 'product-categories');
        $data['category_terms'] = [];
        if ($terms && !is_wp_error($terms)) {
            foreach ($terms as $term) {
                $data['category_terms'][] = [
                    'term_id' => $term->term_id,
                    'name'    => $term->name,
                    'slug'    => $term->slug,
                    'parent'  => $term->parent,
                ];
            }
        }

        // Build category path strings for the frontend el-select
        $data['categories'] = $this->buildCategoryPaths($product->ID);

        return $data;
    }

    /**
     * Build category path strings like ["Clothing > T-Shirts", "Sale"]
     */
    protected function buildCategoryPaths(int $postId): array
    {
        $terms = get_the_terms($postId, 'product-categories');
        if (!$terms || is_wp_error($terms)) {
            return [];
        }

        $paths = [];
        foreach ($terms as $term) {
            $paths[] = $this->getTermPath($term);
        }

        return $paths;
    }

    /**
     * Get the full hierarchical path for a term.
     */
    protected function getTermPath($term): string
    {
        $parts = [$term->name];
        $parentId = $term->parent;

        while ($parentId > 0) {
            $parent = get_term($parentId, 'product-categories');
            if (!$parent || is_wp_error($parent)) {
                break;
            }
            array_unshift($parts, $parent->name);
            $parentId = $parent->parent;
        }

        return implode(' > ', $parts);
    }

    /**
     * Validate product data before update using the framework Validator.
     *
     * @param array $data
     * @return array|null Field-keyed error messages if invalid, null if valid
     */
    protected function validateProduct(array $data): ?array
    {
        $variationType = Arr::get($data, 'detail.variation_type', 'simple');
        $postId = absint(Arr::get($data, 'ID', 0));

        $rules = [
            'post_title'                 => 'required|sanitizeText|maxLength:200',
            'post_status'                => 'required|sanitizeText|in:publish,draft',
            'variants.*.variation_title' => ($variationType === 'simple_variations')
                ? 'required|sanitizeText|maxLength:200'
                : 'nullable|sanitizeText|maxLength:200',
            'variants.*.sku'             => 'nullable|sanitizeText|maxLength:100',
            'variants.*.item_price'      => 'nullable|numeric|min:0',
            'variants.*.compare_price'   => [
                'nullable',
                'numeric',
                function ($attribute, $value, $rules, $allData) {
                    $index = explode('.', $attribute)[1];
                    $itemPrice = Arr::get($allData, "variants.$index.item_price", 0);
                    if (empty($itemPrice)) {
                        $itemPrice = 0;
                    }
                    if ($value !== null && $value < $itemPrice) {
                        return __('Compare price must be greater than or equal to item price.', 'fluent-cart');
                    }
                    return null;
                },
            ],
            'variants.*.other_info'                  => 'required|array',
            'variants.*.other_info.payment_type'     => 'required|sanitizeText|in:onetime,subscription',
            'variants.*.other_info.times'            => [
                function ($attribute, $value, $rules, $allData) {
                    $index = explode('.', $attribute)[1];

                    return Helper::installmentTimesError(Arr::get($allData, "variants.$index.other_info"));
                },
            ],
            // Conditional requirements here are closures, not `required_if`, and
            // the attributes carrying one have no `nullable`: filterExcludeables()
            // drops EVERY rule — closures included — when `nullable` meets a falsy
            // value, which is what left these requirements dead. WhenFilledRule
            // therefore carries the value checks that `nullable` used to guard.
            //
            // manage_setup_fee keeps `nullable` on purpose: it is an optional flag
            // that both services already default to 'no', so demanding it would
            // reject payloads that simply omit it.
            'variants.*.other_info.repeat_interval'  => [
                RequiredWhenRule::make(
                    'variants.*.other_info.payment_type',
                    'subscription',
                    __('Interval is required for subscriptions.', 'fluent-cart')
                ),
                WhenFilledRule::in(
                    ['yearly', 'half_yearly', 'quarterly', 'monthly', 'weekly', 'daily'],
                    __('Interval must be a valid frequency.', 'fluent-cart')
                ),
            ],
            'variants.*.other_info.trial_days'        => 'nullable|numeric|min:0|max:365',
            'variants.*.other_info.manage_setup_fee' => 'nullable|sanitizeText|in:no,yes',
            'variants.*.other_info.signup_fee'       => [
                RequiredWhenRule::make(
                    'variants.*.other_info.manage_setup_fee',
                    'yes',
                    __('Setup Fee Amount is required.', 'fluent-cart')
                ),
                WhenFilledRule::numericAtLeast(
                    0,
                    __('Setup Fee must be a number.', 'fluent-cart'),
                    __('Setup Fee must be 0 or more.', 'fluent-cart')
                ),
            ],
            'variants.*.other_info.signup_fee_name'  => [
                RequiredWhenRule::make(
                    'variants.*.other_info.manage_setup_fee',
                    'yes',
                    __('Setup Fee Name is required.', 'fluent-cart')
                ),
                WhenFilledRule::text(
                    100,
                    __('Setup Fee Name must be plain text of 100 characters or fewer.', 'fluent-cart')
                ),
            ],
        ];

        $messages = [
            'post_title.required'                                => __('Title is required.', 'fluent-cart'),
            'post_title.maxLength'                               => __('Title may not be greater than 200 characters.', 'fluent-cart'),
            'post_status.required'                               => __('Status is required.', 'fluent-cart'),
            'post_status.in'                                     => __('Status must be published or draft.', 'fluent-cart'),
            'variants.*.variation_title.required'                => __('Variant title is required.', 'fluent-cart'),
            'variants.*.variation_title.maxLength'               => __('Variant title may not be greater than 200 characters.', 'fluent-cart'),
            'variants.*.sku.maxLength'                           => __('SKU may not be greater than 100 characters.', 'fluent-cart'),
            'variants.*.item_price.numeric'                      => __('Price must be a number.', 'fluent-cart'),
            'variants.*.item_price.min'                          => __('Price must be a positive number.', 'fluent-cart'),
            'variants.*.other_info.payment_type.required'        => __('Payment Type is required.', 'fluent-cart'),
            'variants.*.other_info.payment_type.in'              => __('Payment Type must be onetime or subscription.', 'fluent-cart'),
            'variants.*.other_info.trial_days.numeric'           => __('Trial days must be a number.', 'fluent-cart'),
            'variants.*.other_info.trial_days.min'               => __('Trial days must be 0 or more.', 'fluent-cart'),
            'variants.*.other_info.trial_days.max'               => __('Trial days may not be greater than 365.', 'fluent-cart'),
            'variants.*.other_info.manage_setup_fee.in'          => __('Setup fee option must be yes or no.', 'fluent-cart'),
        ];

        $validator = Validator::make($data, $rules, $messages);

        if ($validator->fails()) {
            $errors = [];
            foreach ($validator->errors() as $field => $ruleMessages) {
                $errors[$field] = is_array($ruleMessages) ? reset($ruleMessages) : $ruleMessages;
            }
            return $errors;
        }

        // Check for duplicate SKUs within the same product's variants and against DB
        $variants = Arr::get($data, 'variants', []);
        $skus = [];
        $skuErrors = [];
        foreach ($variants as $i => $v) {
            $sku = trim(Arr::get($v, 'sku', ''));
            if (!empty($sku)) {
                if (in_array($sku, $skus, true)) {
                    $skuErrors["variants.$i.sku"] = sprintf(
                        __('Duplicate SKU "%s" within this product.', 'fluent-cart'),
                        $sku
                    );
                } else {
                    // Check DB for SKU used by other products
                    $query = ProductVariation::query()->where('sku', $sku);
                    if ($postId) {
                        $query->where('post_id', '!=', $postId);
                    }
                    if ($query->first()) {
                        $skuErrors["variants.$i.sku"] = sprintf(
                            __('SKU "%s" is already in use by another product.', 'fluent-cart'),
                            $sku
                        );
                    }
                }
                $skus[] = $sku;
            }
        }

        return !empty($skuErrors) ? $skuErrors : null;
    }

    /**
     * Update a chunk of products (max 10).
     *
     * @param array $products
     * @return array { updated: int[], errors: array[] }
     */
    public function updateChunk(array $products): array
    {
        global $wpdb;

        $updated = [];
        $errors = [];

        $wpdb->query('START TRANSACTION');

        try {
            foreach ($products as $index => $productData) {
                $fieldErrors = $this->validateProduct($productData);
                if ($fieldErrors) {
                    $errors[] = [
                        'index'   => $index,
                        'post_id' => Arr::get($productData, 'ID', ''),
                        'title'   => Arr::get($productData, 'post_title', ''),
                        'message' => reset($fieldErrors),
                        'fields'  => $fieldErrors,
                    ];
                    continue;
                }

                try {
                    $postId = $this->updateSingleProduct($productData);
                    $updated[] = $postId;
                } catch (\Throwable $e) {
                    $errors[] = [
                        'index'   => $index,
                        'post_id' => Arr::get($productData, 'ID', ''),
                        'title'   => Arr::get($productData, 'post_title', ''),
                        'message' => $e->getMessage(),
                    ];
                }
            }

            $wpdb->query('COMMIT');
        } catch (\Throwable $e) {
            $wpdb->query('ROLLBACK');
            throw $e;
        }

        return [
            'updated' => $updated,
            'errors'  => $errors,
        ];
    }

    /**
     * Update a single product with its variants and categories.
     */
    protected function updateSingleProduct(array $productData): int
    {
        $postId = absint(Arr::get($productData, 'ID', 0));

        if (!$postId) {
            throw new \RuntimeException(__('Product ID is required', 'fluent-cart'));
        }

        $product = Product::query()->find($postId);
        if (!$product) {
            throw new \RuntimeException(__('Product not found', 'fluent-cart'));
        }

        // Use ProductResource::update for variants/detail (amounts are cents)
        $updatePayload = [];

        // Detail
        if (Arr::has($productData, 'detail')) {
            $detail = Arr::get($productData, 'detail', []);
            // Only pass through fields we allow editing
            $updatePayload['detail'] = [
                'id'                   => $product->detail->id ?? null,
                'default_variation_id' => $product->detail->default_variation_id ?? null,
                'variation_type'       => Arr::get($detail, 'variation_type', $product->detail->variation_type ?? 'simple'),
                'fulfillment_type'     => Arr::get($detail, 'fulfillment_type', $product->detail->fulfillment_type ?? 'physical'),
                'manage_stock'         => Arr::get($detail, 'manage_stock', $product->detail->manage_stock ?? 0),
            ];
        }

        // Variants — separate existing (with id) from new (without id)
        // New variants are created directly; existing ones go through ProductResource::update
        if (Arr::has($productData, 'variants')) {
            $allVariants = Arr::get($productData, 'variants', []);
            $existingVariants = [];
            $newVariants = [];

            foreach ($allVariants as $v) {
                if (!empty($v['id'])) {
                    $existingVariants[] = Arr::except($v, ['media']);
                } else {
                    $newVariants[] = $v;
                }
            }

            $updatePayload['variants'] = $existingVariants;

            // Create new variants (e.g. from variation duplication)
            foreach ($newVariants as $newVariant) {
                $this->createVariantForProduct($postId, $product, $newVariant);
            }
        }

        // Post-level fields
        $postFields = ['post_title', 'post_content', 'post_excerpt', 'post_status'];
        foreach ($postFields as $field) {
            if (Arr::has($productData, $field)) {
                $updatePayload[$field] = Arr::get($productData, $field);
            }
        }

        // Map 'published' status to 'publish' for WordPress
        if (Arr::get($updatePayload, 'post_status') === 'published') {
            $updatePayload['post_status'] = 'publish';
        }

        // Use ProductResource::update which handles the variant and detail writes
        if (!empty($updatePayload['variants']) || !empty($updatePayload['detail'])) {
            ProductResource::update($updatePayload, $postId);
        }

        // Update wp_post fields
        if (array_intersect_key($updatePayload, array_flip($postFields))) {
            $wpPostData = ['ID' => $postId];
            if (Arr::has($updatePayload, 'post_title')) {
                $wpPostData['post_title'] = sanitize_text_field(Arr::get($updatePayload, 'post_title'));
                $wpPostData['post_name'] = sanitize_title(Arr::get($updatePayload, 'post_title'));
            }
            if (Arr::has($updatePayload, 'post_content')) {
                $wpPostData['post_content'] = wp_kses_post(Arr::get($updatePayload, 'post_content'));
            }
            if (Arr::has($updatePayload, 'post_excerpt')) {
                $wpPostData['post_excerpt'] = sanitize_textarea_field(Arr::get($updatePayload, 'post_excerpt'));
            }
            if (Arr::has($updatePayload, 'post_status')) {
                $wpPostData['post_status'] = sanitize_text_field(Arr::get($updatePayload, 'post_status'));
            }
            wp_update_post($wpPostData);
        }

        // Update manage_stock on product detail
        if (Arr::has($productData, 'detail.manage_stock')) {
            $manageStock = Arr::get($productData, 'detail.manage_stock', 0) ? 1 : 0;
            $detail = ProductDetail::query()->where('post_id', $postId)->first();
            if ($detail) {
                $detail->update(['manage_stock' => $manageStock]);

                // Also update all variants' manage_stock
                ProductVariation::query()->where('post_id', $postId)->update([
                    'manage_stock' => $manageStock,
                ]);
            }
        }

        // Update gallery images
        $gallery = Arr::get($productData, 'gallery', null);
        if (is_array($gallery)) {
            $galleryMedia = array_map(function ($img) {
                return [
                    'id'    => absint(Arr::get($img, 'id', 0)),
                    'url'   => esc_url_raw(Arr::get($img, 'url', '')),
                    'title' => sanitize_text_field(Arr::get($img, 'title', '')),
                ];
            }, $gallery);
            $galleryMedia = array_filter($galleryMedia, function ($img) {
                return !empty($img['url']);
            });
            update_post_meta($postId, 'fluent-products-gallery-image', array_values($galleryMedia));
        }

        // Update variant media
        $variants = Arr::get($productData, 'variants', []);
        foreach ($variants as $variantData) {
            $variantId = absint(Arr::get($variantData, 'id', 0));
            $variantMedia = Arr::get($variantData, 'media', null);
            if ($variantId && is_array($variantMedia)) {
                $normalized = array_map(function ($img) {
                    return [
                        'id'    => absint(Arr::get($img, 'id', 0)),
                        'url'   => esc_url_raw(Arr::get($img, 'url', '')),
                        'title' => sanitize_text_field(Arr::get($img, 'title', '')),
                    ];
                }, $variantMedia);
                $normalized = array_values(array_filter($normalized, function ($img) {
                    return !empty($img['url']);
                }));
                ProductVariationResource::setImage($normalized, $variantId);
            }
        }

        // Sync categories
        $categories = Arr::get($productData, 'categories', null);
        if (is_array($categories)) {
            $this->syncCategories($postId, $categories);
        }

        return $postId;
    }

    /**
     * Create a new variant for an existing product (used when duplicating a variant in bulk edit).
     */
    protected function createVariantForProduct(int $postId, Product $product, array $variantData): void
    {
        // Amounts arrive in cents; normalize float artifacts without scaling.
        $priceColumns = ['item_price', 'compare_price', 'item_cost'];
        foreach ($priceColumns as $column) {
            if (Arr::has($variantData, $column)) {
                $variantData[$column] = Helper::roundCent(Arr::get($variantData, $column, 0));
            }
        }

        $otherInfo = Arr::get($variantData, 'other_info', []);
        $media = Arr::get($variantData, 'media', []);

        $maxSerial = ProductVariation::query()->where('post_id', $postId)->max('serial_index');

        $createData = [
            'post_id'         => $postId,
            'variation_title' => sanitize_text_field(Arr::get($variantData, 'variation_title', '')),
            'sku'             => Arr::get($variantData, 'sku') ? sanitize_text_field($variantData['sku']) : null,
            'item_price'      => (int) Arr::get($variantData, 'item_price', 0),
            'compare_price'   => (int) Arr::get($variantData, 'compare_price', 0),
            'payment_type'    => Arr::get($otherInfo, 'payment_type', 'onetime'),
            'manage_stock'    => (int) ($product->detail->manage_stock ?? 0),
            'total_stock'     => (int) Arr::get($variantData, 'available', 0),
            'available'       => (int) Arr::get($variantData, 'available', 0),
            'stock_status'    => Arr::get($variantData, 'stock_status', 'in-stock'),
            'serial_index'    => ($maxSerial ?? 0) + 1,
            'fulfillment_type' => Arr::get($variantData, 'fulfillment_type', $product->detail->fulfillment_type ?? 'physical'),
            'other_info'      => $otherInfo,
        ];

        $newVariant = ProductVariation::query()->create($createData);

        // Set variant media if provided
        if ($newVariant && !empty($media) && is_array($media)) {
            $normalized = array_map(function ($img) {
                return [
                    'id'    => absint(Arr::get($img, 'id', 0)),
                    'url'   => esc_url_raw(Arr::get($img, 'url', '')),
                    'title' => sanitize_text_field(Arr::get($img, 'title', '')),
                ];
            }, $media);
            $normalized = array_values(array_filter($normalized, function ($img) {
                return !empty($img['url']);
            }));
            ProductVariationResource::setImage($normalized, $newVariant->id);
        }
    }

    /**
     * Sync categories for a product.
     * Accepts mixed input: term ID integers, objects with term_id, or path strings.
     */
    public function syncCategories(int $postId, array $categories): void
    {
        if (!function_exists('wp_create_term')) {
            require_once(ABSPATH . 'wp-admin/includes/taxonomy.php');
        }

        $termIds = [];

        foreach ($categories as $category) {
            if (is_numeric($category)) {
                $termIds[] = (int) $category;
            } elseif (is_array($category) && isset($category['term_id'])) {
                $termIds[] = (int) $category['term_id'];
            } elseif (is_string($category) && !empty($category)) {
                // Path string like "Clothing > T-Shirts"
                $resolvedId = $this->resolveTermPath($category);
                if ($resolvedId) {
                    $termIds[] = $resolvedId;
                }
            }
        }

        $termIds = array_unique(array_filter($termIds));
        wp_set_post_terms($postId, $termIds, 'product-categories');
    }

    /**
     * Normalize the money fields in other_info for the bulk edit grid.
     *
     * Despite what this used to say, nothing is converted to dollars: signup_fee
     * stays in CENTS and is only int-cast, because PriceInput does the rendering.
     * Reintroducing a division here would halve-by-100 every setup fee in the grid.
     */
    protected function formatOtherInfoForEdit(array $otherInfo): array
    {
        if (!empty($otherInfo['signup_fee']) && is_numeric($otherInfo['signup_fee'])) {
            $otherInfo['signup_fee'] = (int) $otherInfo['signup_fee'];
        }

        return $otherInfo;
    }

    /**
     * Resolve a category path string to a term ID, creating terms as needed.
     * Reuses the same pattern as BulkProductInsertService::assignCategories.
     */
    protected function resolveTermPath(string $path): int
    {
        $path = sanitize_text_field($path);
        if (empty($path)) {
            return 0;
        }

        $segments = array_map('trim', explode('>', $path));
        $segments = array_filter($segments);
        $parentId = 0;

        foreach ($segments as $name) {
            $existing = term_exists($name, 'product-categories', $parentId ?: null);
            if ($existing) {
                $parentId = (int) (is_array($existing) ? $existing['term_id'] : $existing);
            } else {
                $args = $parentId ? ['parent' => $parentId] : [];
                $created = wp_insert_term($name, 'product-categories', $args);
                if (!is_wp_error($created)) {
                    $parentId = (int) $created['term_id'];
                }
            }
        }

        return $parentId;
    }
}

```
