PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.0
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.6.0, at app/Services/BulkProductUpdateService.php

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