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

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