PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.27
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.27
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Services / BulkProductUpdateService.php

BulkProductUpdateService.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.3.27, at app/Services/BulkProductUpdateService.php

598 lines 25.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services;
4
5 use FluentCart\Api\Resource\ProductResource;
6 use FluentCart\Api\Resource\ProductVariationResource;
7 use FluentCart\App\Models\Product;
8 use FluentCart\App\Models\ProductDetail;
9 use FluentCart\App\Models\ProductVariation;
10 use FluentCart\App\Services\Filter\ProductFilter;
11 use FluentCart\Framework\Http\Request\Request;
12 use FluentCart\Framework\Support\Arr;
13 use FluentCart\Framework\Validator\Validator;
14
15 class BulkProductUpdateService
16 {
17 /**
18 * Fetch products formatted for bulk editing.
19 * Returns products with decimal prices and category terms.
20 */
21 public function fetchForBulkEdit(Request $request): array
22 {
23 $products = ProductFilter::fromRequest($request)->paginate();
24
25 $formatted = $products->getCollection()->map(function ($product) {
26 return $this->formatProductForEdit($product);
27 });
28
29 return [
30 'products' => $formatted->values()->toArray(),
31 'total' => $products->total(),
32 'per_page' => $products->perPage(),
33 'page' => $products->currentPage(),
34 ];
35 }
36
37 /**
38 * Format a single product for the bulk edit spreadsheet.
39 * Converts prices from cents to decimal and attaches categories.
40 */
41 protected function formatProductForEdit(Product $product): array
42 {
43 $product->load([
44 'detail',
45 'variants' => function ($query) {
46 $query->orderBy('serial_index', 'ASC');
47 },
48 'variants.media',
49 ]);
50
51 $data = [
52 'ID' => $product->ID,
53 'post_title' => $product->post_title,
54 'post_content' => $product->post_content,
55 'post_excerpt' => $product->post_excerpt,
56 'post_status' => $product->post_status,
57 'view_url' => get_permalink($product->ID),
58 ];
59
60 // Gallery images
61 $gallery = get_post_meta($product->ID, 'fluent-products-gallery-image', true);
62 $data['gallery'] = (!empty($gallery) && is_array($gallery)) ? $gallery : [];
63
64 // Detail
65 if ($product->detail) {
66 $data['detail'] = [
67 'variation_type' => $product->detail->variation_type,
68 'fulfillment_type' => $product->detail->fulfillment_type,
69 'manage_stock' => (int) $product->detail->manage_stock,
70 ];
71 }
72
73 // Variants — convert prices from cents to decimal
74 $data['variants'] = [];
75 if ($product->variants) {
76 foreach ($product->variants as $variant) {
77 $variantMedia = [];
78 if ($variant->media && is_array($variant->media->meta_value)) {
79 $variantMedia = $variant->media->meta_value;
80 }
81
82 $variantData = [
83 'id' => $variant->id,
84 'post_id' => $variant->post_id,
85 'variation_title' => $variant->variation_title,
86 'sku' => $variant->sku,
87 'item_price' => $variant->item_price / 100,
88 'compare_price' => $variant->compare_price / 100,
89 'payment_type' => $variant->payment_type,
90 'manage_stock' => (int) $variant->manage_stock,
91 'total_stock' => (int) $variant->total_stock,
92 'available' => (int) $variant->available,
93 'stock_status' => $variant->stock_status,
94 'serial_index' => (int) $variant->serial_index,
95 'fulfillment_type' => $variant->fulfillment_type,
96 'other_info' => $this->formatOtherInfoForEdit($variant->other_info ?? []),
97 'media' => $variantMedia,
98 ];
99 $data['variants'][] = $variantData;
100 }
101 }
102
103 // Categories
104 $terms = get_the_terms($product->ID, 'product-categories');
105 $data['category_terms'] = [];
106 if ($terms && !is_wp_error($terms)) {
107 foreach ($terms as $term) {
108 $data['category_terms'][] = [
109 'term_id' => $term->term_id,
110 'name' => $term->name,
111 'slug' => $term->slug,
112 'parent' => $term->parent,
113 ];
114 }
115 }
116
117 // Build category path strings for the frontend el-select
118 $data['categories'] = $this->buildCategoryPaths($product->ID);
119
120 return $data;
121 }
122
123 /**
124 * Build category path strings like ["Clothing > T-Shirts", "Sale"]
125 */
126 protected function buildCategoryPaths(int $postId): array
127 {
128 $terms = get_the_terms($postId, 'product-categories');
129 if (!$terms || is_wp_error($terms)) {
130 return [];
131 }
132
133 $paths = [];
134 foreach ($terms as $term) {
135 $paths[] = $this->getTermPath($term);
136 }
137
138 return $paths;
139 }
140
141 /**
142 * Get the full hierarchical path for a term.
143 */
144 protected function getTermPath($term): string
145 {
146 $parts = [$term->name];
147 $parentId = $term->parent;
148
149 while ($parentId > 0) {
150 $parent = get_term($parentId, 'product-categories');
151 if (!$parent || is_wp_error($parent)) {
152 break;
153 }
154 array_unshift($parts, $parent->name);
155 $parentId = $parent->parent;
156 }
157
158 return implode(' > ', $parts);
159 }
160
161 /**
162 * Validate product data before update using the framework Validator.
163 *
164 * @param array $data
165 * @return array|null Field-keyed error messages if invalid, null if valid
166 */
167 protected function validateProduct(array $data): ?array
168 {
169 $variationType = Arr::get($data, 'detail.variation_type', 'simple');
170 $postId = absint(Arr::get($data, 'ID', 0));
171
172 $rules = [
173 'post_title' => 'required|sanitizeText|maxLength:200',
174 'post_status' => 'required|sanitizeText|in:publish,draft',
175 'variants.*.variation_title' => ($variationType === 'simple_variations')
176 ? 'required|sanitizeText|maxLength:200'
177 : 'nullable|sanitizeText|maxLength:200',
178 'variants.*.sku' => 'nullable|sanitizeText|maxLength:100',
179 'variants.*.item_price' => 'nullable|numeric|min:0',
180 'variants.*.compare_price' => [
181 'nullable',
182 'numeric',
183 function ($attribute, $value, $rules, $allData) {
184 $index = explode('.', $attribute)[1];
185 $itemPrice = Arr::get($allData, "variants.$index.item_price", 0);
186 if (empty($itemPrice)) {
187 $itemPrice = 0;
188 }
189 if ($value !== null && $value < $itemPrice) {
190 return __('Compare price must be greater than or equal to item price.', 'fluent-cart');
191 }
192 return null;
193 },
194 ],
195 'variants.*.other_info' => 'required|array',
196 'variants.*.other_info.payment_type' => 'required|sanitizeText|in:onetime,subscription',
197 'variants.*.other_info.repeat_interval' => 'nullable|required_if:variants.*.other_info.payment_type,subscription|sanitizeText|in:yearly,half_yearly,quarterly,monthly,weekly,daily',
198 'variants.*.other_info.trial_days' => 'nullable|numeric|min:0|max:365',
199 'variants.*.other_info.manage_setup_fee' => 'nullable|required_if:variants.*.other_info.payment_type,subscription|sanitizeText|in:no,yes',
200 'variants.*.other_info.signup_fee' => 'nullable|required_if:variants.*.other_info.manage_setup_fee,yes|numeric|min:0',
201 'variants.*.other_info.signup_fee_name' => 'nullable|required_if:variants.*.other_info.manage_setup_fee,yes|sanitizeText|maxLength:100',
202 ];
203
204 $messages = [
205 'post_title.required' => __('Title is required.', 'fluent-cart'),
206 'post_title.maxLength' => __('Title may not be greater than 200 characters.', 'fluent-cart'),
207 'post_status.required' => __('Status is required.', 'fluent-cart'),
208 'post_status.in' => __('Status must be published or draft.', 'fluent-cart'),
209 'variants.*.variation_title.required' => __('Variant title is required.', 'fluent-cart'),
210 'variants.*.variation_title.maxLength' => __('Variant title may not be greater than 200 characters.', 'fluent-cart'),
211 'variants.*.sku.maxLength' => __('SKU may not be greater than 100 characters.', 'fluent-cart'),
212 'variants.*.item_price.numeric' => __('Price must be a number.', 'fluent-cart'),
213 'variants.*.item_price.min' => __('Price must be a positive number.', 'fluent-cart'),
214 'variants.*.other_info.payment_type.required' => __('Payment Type is required.', 'fluent-cart'),
215 'variants.*.other_info.payment_type.in' => __('Payment Type must be onetime or subscription.', 'fluent-cart'),
216 'variants.*.other_info.repeat_interval.required_if' => __('Interval is required for subscriptions.', 'fluent-cart'),
217 'variants.*.other_info.repeat_interval.in' => __('Interval must be a valid frequency.', 'fluent-cart'),
218 'variants.*.other_info.trial_days.numeric' => __('Trial days must be a number.', 'fluent-cart'),
219 'variants.*.other_info.trial_days.min' => __('Trial days must be 0 or more.', 'fluent-cart'),
220 'variants.*.other_info.trial_days.max' => __('Trial days may not be greater than 365.', 'fluent-cart'),
221 'variants.*.other_info.manage_setup_fee.in' => __('Setup fee option must be yes or no.', 'fluent-cart'),
222 'variants.*.other_info.signup_fee.required_if' => __('Setup Fee Amount is required.', 'fluent-cart'),
223 'variants.*.other_info.signup_fee.numeric' => __('Setup Fee must be a number.', 'fluent-cart'),
224 'variants.*.other_info.signup_fee_name.required_if' => __('Setup Fee Name is required.', 'fluent-cart'),
225 ];
226
227 $validator = Validator::make($data, $rules, $messages);
228
229 if ($validator->fails()) {
230 $errors = [];
231 foreach ($validator->errors() as $field => $ruleMessages) {
232 $errors[$field] = is_array($ruleMessages) ? reset($ruleMessages) : $ruleMessages;
233 }
234 return $errors;
235 }
236
237 // Check for duplicate SKUs within the same product's variants and against DB
238 $variants = Arr::get($data, 'variants', []);
239 $skus = [];
240 $skuErrors = [];
241 foreach ($variants as $i => $v) {
242 $sku = trim(Arr::get($v, 'sku', ''));
243 if (!empty($sku)) {
244 if (in_array($sku, $skus, true)) {
245 $skuErrors["variants.$i.sku"] = sprintf(
246 __('Duplicate SKU "%s" within this product.', 'fluent-cart'),
247 $sku
248 );
249 } else {
250 // Check DB for SKU used by other products
251 $query = ProductVariation::query()->where('sku', $sku);
252 if ($postId) {
253 $query->where('post_id', '!=', $postId);
254 }
255 if ($query->first()) {
256 $skuErrors["variants.$i.sku"] = sprintf(
257 __('SKU "%s" is already in use by another product.', 'fluent-cart'),
258 $sku
259 );
260 }
261 }
262 $skus[] = $sku;
263 }
264 }
265
266 return !empty($skuErrors) ? $skuErrors : null;
267 }
268
269 /**
270 * Update a chunk of products (max 10).
271 *
272 * @param array $products
273 * @return array { updated: int[], errors: array[] }
274 */
275 public function updateChunk(array $products): array
276 {
277 global $wpdb;
278
279 $updated = [];
280 $errors = [];
281
282 $wpdb->query('START TRANSACTION');
283
284 try {
285 foreach ($products as $index => $productData) {
286 $fieldErrors = $this->validateProduct($productData);
287 if ($fieldErrors) {
288 $errors[] = [
289 'index' => $index,
290 'post_id' => Arr::get($productData, 'ID', ''),
291 'title' => Arr::get($productData, 'post_title', ''),
292 'message' => reset($fieldErrors),
293 'fields' => $fieldErrors,
294 ];
295 continue;
296 }
297
298 try {
299 $postId = $this->updateSingleProduct($productData);
300 $updated[] = $postId;
301 } catch (\Throwable $e) {
302 $errors[] = [
303 'index' => $index,
304 'post_id' => Arr::get($productData, 'ID', ''),
305 'title' => Arr::get($productData, 'post_title', ''),
306 'message' => $e->getMessage(),
307 ];
308 }
309 }
310
311 $wpdb->query('COMMIT');
312 } catch (\Throwable $e) {
313 $wpdb->query('ROLLBACK');
314 throw $e;
315 }
316
317 return [
318 'updated' => $updated,
319 'errors' => $errors,
320 ];
321 }
322
323 /**
324 * Update a single product with its variants and categories.
325 */
326 protected function updateSingleProduct(array $productData): int
327 {
328 $postId = absint(Arr::get($productData, 'ID', 0));
329
330 if (!$postId) {
331 throw new \RuntimeException(__('Product ID is required', 'fluent-cart'));
332 }
333
334 $product = Product::query()->find($postId);
335 if (!$product) {
336 throw new \RuntimeException(__('Product not found', 'fluent-cart'));
337 }
338
339 // Use ProductResource::update for variants/detail (handles price * 100)
340 $updatePayload = [];
341
342 // Detail
343 if (Arr::has($productData, 'detail')) {
344 $detail = Arr::get($productData, 'detail', []);
345 // Only pass through fields we allow editing
346 $updatePayload['detail'] = [
347 'id' => $product->detail->id ?? null,
348 'default_variation_id' => $product->detail->default_variation_id ?? null,
349 'variation_type' => Arr::get($detail, 'variation_type', $product->detail->variation_type ?? 'simple'),
350 'fulfillment_type' => Arr::get($detail, 'fulfillment_type', $product->detail->fulfillment_type ?? 'physical'),
351 'manage_stock' => Arr::get($detail, 'manage_stock', $product->detail->manage_stock ?? 0),
352 ];
353 }
354
355 // Variants — separate existing (with id) from new (without id)
356 // New variants are created directly; existing ones go through ProductResource::update
357 if (Arr::has($productData, 'variants')) {
358 $allVariants = Arr::get($productData, 'variants', []);
359 $existingVariants = [];
360 $newVariants = [];
361
362 foreach ($allVariants as $v) {
363 if (!empty($v['id'])) {
364 $existingVariants[] = Arr::except($v, ['media']);
365 } else {
366 $newVariants[] = $v;
367 }
368 }
369
370 $updatePayload['variants'] = $existingVariants;
371
372 // Create new variants (e.g. from variation duplication)
373 foreach ($newVariants as $newVariant) {
374 $this->createVariantForProduct($postId, $product, $newVariant);
375 }
376 }
377
378 // Post-level fields
379 $postFields = ['post_title', 'post_content', 'post_excerpt', 'post_status'];
380 foreach ($postFields as $field) {
381 if (Arr::has($productData, $field)) {
382 $updatePayload[$field] = Arr::get($productData, $field);
383 }
384 }
385
386 // Map 'published' status to 'publish' for WordPress
387 if (Arr::get($updatePayload, 'post_status') === 'published') {
388 $updatePayload['post_status'] = 'publish';
389 }
390
391 // Use ProductResource::update which handles price conversion and variant updates
392 if (!empty($updatePayload['variants']) || !empty($updatePayload['detail'])) {
393 ProductResource::update($updatePayload, $postId);
394 }
395
396 // Update wp_post fields
397 if (array_intersect_key($updatePayload, array_flip($postFields))) {
398 $wpPostData = ['ID' => $postId];
399 if (Arr::has($updatePayload, 'post_title')) {
400 $wpPostData['post_title'] = sanitize_text_field(Arr::get($updatePayload, 'post_title'));
401 $wpPostData['post_name'] = sanitize_title(Arr::get($updatePayload, 'post_title'));
402 }
403 if (Arr::has($updatePayload, 'post_content')) {
404 $wpPostData['post_content'] = wp_kses_post(Arr::get($updatePayload, 'post_content'));
405 }
406 if (Arr::has($updatePayload, 'post_excerpt')) {
407 $wpPostData['post_excerpt'] = sanitize_textarea_field(Arr::get($updatePayload, 'post_excerpt'));
408 }
409 if (Arr::has($updatePayload, 'post_status')) {
410 $wpPostData['post_status'] = sanitize_text_field(Arr::get($updatePayload, 'post_status'));
411 }
412 wp_update_post($wpPostData);
413 }
414
415 // Update manage_stock on product detail
416 if (Arr::has($productData, 'detail.manage_stock')) {
417 $manageStock = Arr::get($productData, 'detail.manage_stock', 0) ? 1 : 0;
418 $detail = ProductDetail::query()->where('post_id', $postId)->first();
419 if ($detail) {
420 $detail->update(['manage_stock' => $manageStock]);
421
422 // Also update all variants' manage_stock
423 ProductVariation::query()->where('post_id', $postId)->update([
424 'manage_stock' => $manageStock,
425 ]);
426 }
427 }
428
429 // Update gallery images
430 $gallery = Arr::get($productData, 'gallery', null);
431 if (is_array($gallery)) {
432 $galleryMedia = array_map(function ($img) {
433 return [
434 'id' => absint(Arr::get($img, 'id', 0)),
435 'url' => esc_url_raw(Arr::get($img, 'url', '')),
436 'title' => sanitize_text_field(Arr::get($img, 'title', '')),
437 ];
438 }, $gallery);
439 $galleryMedia = array_filter($galleryMedia, function ($img) {
440 return !empty($img['url']);
441 });
442 update_post_meta($postId, 'fluent-products-gallery-image', array_values($galleryMedia));
443 }
444
445 // Update variant media
446 $variants = Arr::get($productData, 'variants', []);
447 foreach ($variants as $variantData) {
448 $variantId = absint(Arr::get($variantData, 'id', 0));
449 $variantMedia = Arr::get($variantData, 'media', null);
450 if ($variantId && is_array($variantMedia)) {
451 $normalized = array_map(function ($img) {
452 return [
453 'id' => absint(Arr::get($img, 'id', 0)),
454 'url' => esc_url_raw(Arr::get($img, 'url', '')),
455 'title' => sanitize_text_field(Arr::get($img, 'title', '')),
456 ];
457 }, $variantMedia);
458 $normalized = array_values(array_filter($normalized, function ($img) {
459 return !empty($img['url']);
460 }));
461 ProductVariationResource::setImage($normalized, $variantId);
462 }
463 }
464
465 // Sync categories
466 $categories = Arr::get($productData, 'categories', null);
467 if (is_array($categories)) {
468 $this->syncCategories($postId, $categories);
469 }
470
471 return $postId;
472 }
473
474 /**
475 * Create a new variant for an existing product (used when duplicating a variant in bulk edit).
476 */
477 protected function createVariantForProduct(int $postId, Product $product, array $variantData): void
478 {
479 $priceColumns = ['item_price', 'compare_price', 'item_cost'];
480 foreach ($priceColumns as $column) {
481 if (Arr::has($variantData, $column)) {
482 $variantData[$column] = floatval(Arr::get($variantData, $column, 0)) * 100;
483 }
484 }
485
486 $otherInfo = Arr::get($variantData, 'other_info', []);
487 $media = Arr::get($variantData, 'media', []);
488
489 $maxSerial = ProductVariation::query()->where('post_id', $postId)->max('serial_index');
490
491 $createData = [
492 'post_id' => $postId,
493 'variation_title' => sanitize_text_field(Arr::get($variantData, 'variation_title', '')),
494 'sku' => Arr::get($variantData, 'sku') ? sanitize_text_field($variantData['sku']) : null,
495 'item_price' => (int) Arr::get($variantData, 'item_price', 0),
496 'compare_price' => (int) Arr::get($variantData, 'compare_price', 0),
497 'payment_type' => Arr::get($otherInfo, 'payment_type', 'onetime'),
498 'manage_stock' => (int) ($product->detail->manage_stock ?? 0),
499 'total_stock' => (int) Arr::get($variantData, 'available', 0),
500 'available' => (int) Arr::get($variantData, 'available', 0),
501 'stock_status' => Arr::get($variantData, 'stock_status', 'in-stock'),
502 'serial_index' => ($maxSerial ?? 0) + 1,
503 'fulfillment_type' => Arr::get($variantData, 'fulfillment_type', $product->detail->fulfillment_type ?? 'physical'),
504 'other_info' => $otherInfo,
505 ];
506
507 $newVariant = ProductVariation::query()->create($createData);
508
509 // Set variant media if provided
510 if ($newVariant && !empty($media) && is_array($media)) {
511 $normalized = array_map(function ($img) {
512 return [
513 'id' => absint(Arr::get($img, 'id', 0)),
514 'url' => esc_url_raw(Arr::get($img, 'url', '')),
515 'title' => sanitize_text_field(Arr::get($img, 'title', '')),
516 ];
517 }, $media);
518 $normalized = array_values(array_filter($normalized, function ($img) {
519 return !empty($img['url']);
520 }));
521 ProductVariationResource::setImage($normalized, $newVariant->id);
522 }
523 }
524
525 /**
526 * Sync categories for a product.
527 * Accepts mixed input: term ID integers, objects with term_id, or path strings.
528 */
529 public function syncCategories(int $postId, array $categories): void
530 {
531 if (!function_exists('wp_create_term')) {
532 require_once(ABSPATH . 'wp-admin/includes/taxonomy.php');
533 }
534
535 $termIds = [];
536
537 foreach ($categories as $category) {
538 if (is_numeric($category)) {
539 $termIds[] = (int) $category;
540 } elseif (is_array($category) && isset($category['term_id'])) {
541 $termIds[] = (int) $category['term_id'];
542 } elseif (is_string($category) && !empty($category)) {
543 // Path string like "Clothing > T-Shirts"
544 $resolvedId = $this->resolveTermPath($category);
545 if ($resolvedId) {
546 $termIds[] = $resolvedId;
547 }
548 }
549 }
550
551 $termIds = array_unique(array_filter($termIds));
552 wp_set_post_terms($postId, $termIds, 'product-categories');
553 }
554
555 /**
556 * Convert cents-based fields in other_info to dollars for frontend display.
557 */
558 protected function formatOtherInfoForEdit(array $otherInfo): array
559 {
560 if (!empty($otherInfo['signup_fee']) && is_numeric($otherInfo['signup_fee'])) {
561 $otherInfo['signup_fee'] = (float) $otherInfo['signup_fee'] / 100;
562 }
563
564 return $otherInfo;
565 }
566
567 /**
568 * Resolve a category path string to a term ID, creating terms as needed.
569 * Reuses the same pattern as BulkProductInsertService::assignCategories.
570 */
571 protected function resolveTermPath(string $path): int
572 {
573 $path = sanitize_text_field($path);
574 if (empty($path)) {
575 return 0;
576 }
577
578 $segments = array_map('trim', explode('>', $path));
579 $segments = array_filter($segments);
580 $parentId = 0;
581
582 foreach ($segments as $name) {
583 $existing = term_exists($name, 'product-categories', $parentId ?: null);
584 if ($existing) {
585 $parentId = (int) (is_array($existing) ? $existing['term_id'] : $existing);
586 } else {
587 $args = $parentId ? ['parent' => $parentId] : [];
588 $created = wp_insert_term($name, 'product-categories', $args);
589 if (!is_wp_error($created)) {
590 $parentId = (int) $created['term_id'];
591 }
592 }
593 }
594
595 return $parentId;
596 }
597 }
598