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

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