PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Services / BulkProductUpdateService.php

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

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