PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.26
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.26
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 / BulkProductInsertService.php

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

529 lines 23.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\ProductVariationResource;
6 use FluentCart\App\CPT\FluentProducts;
7 use FluentCart\App\Models\ProductDetail;
8 use FluentCart\App\Models\ProductVariation;
9 use FluentCart\Framework\Support\Arr;
10 use FluentCart\Framework\Validator\Validator;
11
12 class BulkProductInsertService
13 {
14 /**
15 * Insert a chunk of products within a database transaction.
16 * Each product is validated before insert — valid ones are inserted,
17 * invalid ones are skipped with an error keyed by _cid.
18 *
19 * @param array $products
20 * @return array { created: array[], errors: array[] }
21 */
22 public function insertChunk(array $products): array
23 {
24 global $wpdb;
25
26 $created = [];
27 $errors = [];
28
29 $wpdb->query('START TRANSACTION');
30
31 try {
32 foreach ($products as $index => $productData) {
33 $cid = sanitize_text_field(Arr::get($productData, '_cid', ''));
34
35 $fieldErrors = $this->validateProduct($productData);
36 if ($fieldErrors) {
37 $errors[] = [
38 '_cid' => $cid,
39 'title' => Arr::get($productData, 'post_title', ''),
40 'message' => reset($fieldErrors),
41 'fields' => $fieldErrors,
42 ];
43 continue;
44 }
45
46 try {
47 $productId = $this->insertSingleProduct($productData);
48 $created[] = ['_cid' => $cid, 'id' => $productId, 'view_url' => get_permalink($productId)];
49 } catch (\Throwable $e) {
50 $errors[] = [
51 '_cid' => $cid,
52 'title' => Arr::get($productData, 'post_title', ''),
53 'message' => $e->getMessage(),
54 ];
55 }
56 }
57
58 $wpdb->query('COMMIT');
59 } catch (\Throwable $e) {
60 $wpdb->query('ROLLBACK');
61 throw $e;
62 }
63
64 return [
65 'created' => $created,
66 'errors' => $errors,
67 ];
68 }
69
70 /**
71 * Validate product data before insertion using the framework Validator.
72 *
73 * @param array $data
74 * @return array|null Field-keyed error messages if invalid, null if valid
75 */
76 protected function validateProduct(array $data): ?array
77 {
78 $variationType = Arr::get($data, 'detail.variation_type', 'simple');
79
80 $rules = [
81 'post_title' => 'required|sanitizeText|maxLength:200',
82 'post_status' => 'required|sanitizeText|in:published,draft',
83 'detail.fulfillment_type' => 'required|sanitizeText|in:physical,digital',
84 'detail.variation_type' => 'required|sanitizeText|in:simple,simple_variations',
85 'variants.*.variation_title' => ($variationType === 'simple_variations')
86 ? 'required|sanitizeText|maxLength:200'
87 : 'nullable|sanitizeText|maxLength:200',
88 'variants.*.sku' => 'nullable|sanitizeText|maxLength:100',
89 'variants.*.item_price' => 'nullable|numeric|min:0',
90 'variants.*.compare_price' => [
91 'nullable',
92 'numeric',
93 function ($attribute, $value, $rules, $allData) {
94 $index = explode('.', $attribute)[1];
95 $itemPrice = Arr::get($allData, "variants.$index.item_price", 0);
96 if (empty($itemPrice)) {
97 $itemPrice = 0;
98 }
99 if ($value !== null && $value < $itemPrice) {
100 return __('Compare price must be greater than or equal to item price.', 'fluent-cart');
101 }
102 return null;
103 },
104 ],
105 'variants.*.other_info' => 'required|array',
106 'variants.*.other_info.payment_type' => 'required|sanitizeText|in:onetime,subscription',
107 'variants.*.other_info.repeat_interval' => 'nullable|required_if:variants.*.other_info.payment_type,subscription|sanitizeText|in:yearly,half_yearly,quarterly,monthly,weekly,daily',
108 'variants.*.other_info.trial_days' => 'nullable|numeric|min:0|max:365',
109 'variants.*.other_info.manage_setup_fee' => 'nullable|required_if:variants.*.other_info.payment_type,subscription|sanitizeText|in:no,yes',
110 'variants.*.other_info.signup_fee' => 'nullable|required_if:variants.*.other_info.manage_setup_fee,yes|numeric|min:0',
111 'variants.*.other_info.signup_fee_name' => 'nullable|required_if:variants.*.other_info.manage_setup_fee,yes|sanitizeText|maxLength:100',
112 ];
113
114 $messages = [
115 'post_title.required' => __('Title is required.', 'fluent-cart'),
116 'post_title.maxLength' => __('Title may not be greater than 200 characters.', 'fluent-cart'),
117 'post_status.required' => __('Status is required.', 'fluent-cart'),
118 'post_status.in' => __('Status must be published or draft.', 'fluent-cart'),
119 'detail.fulfillment_type.required' => __('Product Type is required.', 'fluent-cart'),
120 'detail.fulfillment_type.in' => __('Product Type must be physical or digital.', 'fluent-cart'),
121 'detail.variation_type.required' => __('Pricing Type is required.', 'fluent-cart'),
122 'detail.variation_type.in' => __('Pricing Type must be simple or simple_variations.', 'fluent-cart'),
123 'variants.*.variation_title.required' => __('Variant title is required.', 'fluent-cart'),
124 'variants.*.variation_title.maxLength' => __('Variant title may not be greater than 200 characters.', 'fluent-cart'),
125 'variants.*.sku.maxLength' => __('SKU may not be greater than 100 characters.', 'fluent-cart'),
126 'variants.*.item_price.numeric' => __('Price must be a number.', 'fluent-cart'),
127 'variants.*.item_price.min' => __('Price must be a positive number.', 'fluent-cart'),
128 'variants.*.other_info.payment_type.required' => __('Payment Type is required.', 'fluent-cart'),
129 'variants.*.other_info.payment_type.in' => __('Payment Type must be onetime or subscription.', 'fluent-cart'),
130 'variants.*.other_info.repeat_interval.required_if' => __('Interval is required for subscriptions.', 'fluent-cart'),
131 'variants.*.other_info.repeat_interval.in' => __('Interval must be a valid frequency.', 'fluent-cart'),
132 'variants.*.other_info.trial_days.numeric' => __('Trial days must be a number.', 'fluent-cart'),
133 'variants.*.other_info.trial_days.min' => __('Trial days must be 0 or more.', 'fluent-cart'),
134 'variants.*.other_info.trial_days.max' => __('Trial days may not be greater than 365.', 'fluent-cart'),
135 'variants.*.other_info.manage_setup_fee.in' => __('Setup fee option must be yes or no.', 'fluent-cart'),
136 'variants.*.other_info.signup_fee.required_if' => __('Setup Fee Amount is required.', 'fluent-cart'),
137 'variants.*.other_info.signup_fee.numeric' => __('Setup Fee must be a number.', 'fluent-cart'),
138 'variants.*.other_info.signup_fee_name.required_if' => __('Setup Fee Name is required.', 'fluent-cart'),
139 ];
140
141 $validator = Validator::make($data, $rules, $messages);
142
143 if ($validator->fails()) {
144 // Flatten: ['field' => ['rule' => 'msg', ...]] → ['field' => 'first msg']
145 $errors = [];
146 foreach ($validator->errors() as $field => $ruleMessages) {
147 $errors[$field] = is_array($ruleMessages) ? reset($ruleMessages) : $ruleMessages;
148 }
149 return $errors;
150 }
151
152 // Check for duplicate SKUs within the same product's variants
153 $variants = Arr::get($data, 'variants', []);
154 $skus = [];
155 $skuErrors = [];
156 foreach ($variants as $i => $v) {
157 $sku = trim(Arr::get($v, 'sku', ''));
158 if (!empty($sku)) {
159 if (in_array($sku, $skus, true)) {
160 $skuErrors["variants.$i.sku"] = sprintf(
161 __('Duplicate SKU "%s" within this product.', 'fluent-cart'),
162 $sku
163 );
164 }
165 $skus[] = $sku;
166 }
167 }
168
169 return !empty($skuErrors) ? $skuErrors : null;
170 }
171
172 /**
173 * Insert a single product with its detail and all variants.
174 *
175 * @param array $productData
176 * @return int The created post ID
177 */
178 protected function insertSingleProduct(array $productData): int
179 {
180 $postTitle = sanitize_text_field(Arr::get($productData, 'post_title', ''));
181 $postStatus = sanitize_text_field(Arr::get($productData, 'post_status', 'draft'));
182 $postContent = wp_kses_post(Arr::get($productData, 'post_content', ''));
183 $postExcerpt = sanitize_textarea_field(Arr::get($productData, 'post_excerpt', ''));
184
185 if (empty($postTitle)) {
186 throw new \RuntimeException(__('Product title is required', 'fluent-cart'));
187 }
188
189 // Map 'published' status to 'publish' for WordPress
190 if ($postStatus === 'published') {
191 $postStatus = 'publish';
192 }
193
194 $postData = [
195 'post_title' => $postTitle,
196 'post_name' => sanitize_title($postTitle),
197 'post_content' => $postContent,
198 'post_excerpt' => $postExcerpt,
199 'post_status' => $postStatus,
200 'post_type' => FluentProducts::CPT_NAME,
201 'post_author' => get_current_user_id(),
202 ];
203
204 $createdPostId = wp_insert_post($postData);
205
206 if (is_wp_error($createdPostId)) {
207 throw new \RuntimeException($createdPostId->get_error_message());
208 }
209
210 $detail = Arr::get($productData, 'detail', []);
211 $fulfillmentType = sanitize_text_field(Arr::get($detail, 'fulfillment_type', 'physical'));
212 $variationType = sanitize_text_field(Arr::get($detail, 'variation_type', 'simple'));
213 $manageStock = Arr::get($detail, 'manage_stock', 0) ? 1 : 0;
214
215 $createdDetail = ProductDetail::query()->create([
216 'post_id' => $createdPostId,
217 'fulfillment_type' => $fulfillmentType,
218 'variation_type' => $variationType,
219 'manage_stock' => $manageStock,
220 'stock_availability' => 'in-stock',
221 ]);
222
223 if (!$createdDetail) {
224 throw new \RuntimeException(__('Failed to create product detail', 'fluent-cart'));
225 }
226
227 $variants = Arr::get($productData, 'variants', []);
228 $firstVariantId = null;
229
230 // For simple products the frontend stores manage_stock / total_stock in
231 // 'detail' — sync them into the first variant so createVariant() picks
232 // them up instead of using the dummy defaults.
233 if ($variationType === 'simple' && !empty($variants) && is_array($variants)) {
234 $variants[0]['manage_stock'] = $manageStock;
235 if ($manageStock) {
236 $detailStock = absint(Arr::get($detail, 'total_stock', 100));
237 $variants[0]['total_stock'] = $detailStock;
238 $variants[0]['available'] = $detailStock;
239 }
240 }
241
242 try {
243 if (!empty($variants) && is_array($variants)) {
244 foreach ($variants as $variantIndex => $variantData) {
245 $variantId = $this->createVariant($createdPostId, $variantData, $variantIndex + 1, $fulfillmentType, $postTitle);
246 if (!$firstVariantId) {
247 $firstVariantId = $variantId;
248 }
249 }
250 } else {
251 // Create one default variant (same as ProductController::create)
252 $defaultVariantData = [
253 'item_price' => $this->sanitizePrice(Arr::get($detail, 'item_price', 0)),
254 'compare_price' => $this->sanitizePrice(Arr::get($detail, 'compare_price', 0)),
255 ];
256
257 if ($manageStock) {
258 $totalStock = absint(Arr::get($detail, 'total_stock', 100));
259 $defaultVariantData['total_stock'] = $totalStock;
260 $defaultVariantData['available'] = $totalStock;
261 }
262
263 $firstVariantId = $this->createDefaultVariant($createdPostId, $postTitle, $fulfillmentType, $defaultVariantData);
264 }
265 } catch (\Throwable $e) {
266 // Clean up the orphaned post and detail so we don't leave
267 // products without variants in the database.
268 ProductVariation::query()->where('post_id', $createdPostId)->delete();
269 $createdDetail->delete();
270 wp_delete_post($createdPostId, true);
271 throw $e;
272 }
273
274 $detailUpdate = [];
275 if ($firstVariantId) {
276 $detailUpdate['default_variation_id'] = $firstVariantId;
277 }
278
279 // Derive stock_availability: if manage_stock is on, check variant availability
280 if ($manageStock) {
281 $totalAvailable = ProductVariation::query()
282 ->where('post_id', $createdPostId)
283 ->sum('available');
284 $detailUpdate['stock_availability'] = $totalAvailable > 0 ? 'in-stock' : 'out-of-stock';
285 }
286
287 // Calculate min_price / max_price from created variants
288 $variantPriceRange = ProductVariation::query()
289 ->where('post_id', $createdPostId)
290 ->selectRaw('MIN(item_price) as min_price, MAX(item_price) as max_price')
291 ->first();
292
293 if ($variantPriceRange) {
294 $detailUpdate['min_price'] = $variantPriceRange->min_price ?: 0;
295 $detailUpdate['max_price'] = $variantPriceRange->max_price ?: 0;
296 }
297
298 if (!empty($detailUpdate)) {
299 $createdDetail->update($detailUpdate);
300 }
301
302 // Handle categories
303 $categories = Arr::get($productData, 'categories', []);
304 if (!empty($categories) && is_array($categories)) {
305 $this->assignCategories($createdPostId, $categories);
306 }
307
308 // Handle product gallery — store all media (uploaded with real id, external URLs with id 0)
309 $gallery = Arr::get($productData, 'gallery', []);
310 if (!empty($gallery) && is_array($gallery)) {
311 $galleryMedia = $this->normalizeMedia($gallery);
312 if (!empty($galleryMedia)) {
313 update_post_meta($createdPostId, 'fluent-products-gallery-image', $galleryMedia);
314 }
315 }
316
317 return (int) $createdPostId;
318 }
319
320 /**
321 * Create a product variant from import data.
322 */
323 protected function createVariant(int $postId, array $variantData, int $serialIndex, string $fulfillmentType, string $productTitle = ''): int
324 {
325 $variationTitle = sanitize_text_field(Arr::get($variantData, 'variation_title', ''));
326 if (empty($variationTitle)) {
327 $variationTitle = $productTitle;
328 }
329 $itemPrice = $this->sanitizePrice(Arr::get($variantData, 'item_price', 0));
330 $comparePrice = $this->sanitizePrice(Arr::get($variantData, 'compare_price', 0));
331 $sku = sanitize_text_field(Arr::get($variantData, 'sku', ''));
332 $otherInfo = Arr::get($variantData, 'other_info', []);
333 $paymentType = sanitize_text_field(Arr::get($otherInfo, 'payment_type', 'onetime'));
334 $manageStock = Arr::get($variantData, 'manage_stock', 0) ? 1 : 0;
335
336 if ($manageStock) {
337 $totalStock = absint(Arr::get($variantData, 'total_stock', 0));
338 $available = absint(Arr::get($variantData, 'available', $totalStock));
339 $stockStatus = $available > 0 ? 'in-stock' : 'out-of-stock';
340 } else {
341 $totalStock = 0;
342 $available = 0;
343 $stockStatus = 'in-stock';
344 }
345
346 // Check SKU uniqueness before inserting to avoid raw DB constraint errors
347 if (!empty($sku)) {
348 $existingSku = ProductVariation::query()->where('sku', $sku)->first();
349 if ($existingSku) {
350 throw new \RuntimeException(
351 sprintf(__('SKU "%s" is already in use.', 'fluent-cart'), $sku)
352 );
353 }
354 }
355
356 $variant = ProductVariation::query()->create([
357 'post_id' => $postId,
358 'serial_index' => $serialIndex,
359 'variation_title' => $variationTitle,
360 'sku' => !empty($sku) ? $sku : null,
361 'item_price' => $itemPrice,
362 'compare_price' => $comparePrice,
363 'stock_status' => $stockStatus,
364 'payment_type' => $paymentType,
365 'manage_stock' => $manageStock,
366 'total_stock' => $totalStock,
367 'available' => $available,
368 'fulfillment_type' => $fulfillmentType,
369 'other_info' => [
370 'description' => sanitize_text_field(Arr::get($otherInfo, 'description', '')),
371 'payment_type' => $paymentType,
372 'installment' => sanitize_text_field(Arr::get($otherInfo, 'installment', 'no')),
373 'times' => sanitize_text_field(Arr::get($otherInfo, 'times', '')),
374 'repeat_interval' => sanitize_text_field(Arr::get($otherInfo, 'repeat_interval', '')),
375 'trial_days' => sanitize_text_field(Arr::get($otherInfo, 'trial_days', '')),
376 'billing_summary' => '',
377 'manage_setup_fee' => sanitize_text_field(Arr::get($otherInfo, 'manage_setup_fee', 'no')),
378 'signup_fee_name' => sanitize_text_field(Arr::get($otherInfo, 'signup_fee_name', '')),
379 'signup_fee' => $this->sanitizePrice(Arr::get($otherInfo, 'signup_fee', '')),
380 'setup_fee_per_item' => sanitize_text_field(Arr::get($otherInfo, 'setup_fee_per_item', 'no')),
381 ],
382 ]);
383
384 // Handle variant media — store all media (uploaded with real id, external URLs with id 0)
385 $media = Arr::get($variantData, 'media', []);
386 if (!empty($media) && is_array($media)) {
387 $variantMedia = $this->normalizeMedia($media);
388 if (!empty($variantMedia)) {
389 ProductVariationResource::setImage($variantMedia, $variant->id);
390 }
391 }
392
393 return (int) $variant->id;
394 }
395
396 /**
397 * Create a default variant for a product (when no variants are provided).
398 */
399 protected function createDefaultVariant(int $postId, string $title, string $fulfillmentType, array $extra = []): int
400 {
401 $sku = sanitize_text_field(Arr::get($extra, 'sku', ''));
402
403 // Check SKU uniqueness before inserting to avoid raw DB constraint errors
404 if (!empty($sku)) {
405 $existingSku = ProductVariation::query()->where('sku', $sku)->first();
406 if ($existingSku) {
407 throw new \RuntimeException(
408 sprintf(__('SKU "%s" is already in use.', 'fluent-cart'), $sku)
409 );
410 }
411 }
412
413 $variant = ProductVariation::query()->create(array_merge([
414 'post_id' => $postId,
415 'serial_index' => 1,
416 'variation_title' => $title,
417 'sku' => !empty($sku) ? $sku : null,
418 'stock_status' => 'in-stock',
419 'payment_type' => 'onetime',
420 'total_stock' => 0,
421 'available' => 0,
422 'fulfillment_type' => $fulfillmentType,
423 'other_info' => [
424 'description' => '',
425 'payment_type' => 'onetime',
426 'times' => '',
427 'repeat_interval' => '',
428 'trial_days' => '',
429 'billing_summary' => '',
430 'manage_setup_fee' => 'no',
431 'signup_fee_name' => '',
432 'signup_fee' => '',
433 'setup_fee_per_item' => 'no',
434 ],
435 ], $extra));
436
437 return (int) $variant->id;
438 }
439
440 /**
441 * Assign categories to a product, supporting hierarchy via ">" syntax.
442 * e.g. ["Clothing", "Clothing > T-Shirts", "Sale"]
443 */
444 protected function assignCategories(int $postId, array $categoryPaths): void
445 {
446 if (!function_exists('wp_create_term')) {
447 require_once(ABSPATH . 'wp-admin/includes/taxonomy.php');
448 }
449
450 $termIds = [];
451
452 foreach ($categoryPaths as $path) {
453 $path = sanitize_text_field($path);
454 if (empty($path)) {
455 continue;
456 }
457
458 // Support hierarchy: "Parent > Child > Grandchild"
459 $segments = array_map('trim', explode('>', $path));
460 $segments = array_filter($segments);
461 $parentId = 0;
462
463 foreach ($segments as $name) {
464 $existing = term_exists($name, 'product-categories', $parentId ?: null);
465 if ($existing) {
466 $parentId = (int) (is_array($existing) ? $existing['term_id'] : $existing);
467 } else {
468 $args = $parentId ? ['parent' => $parentId] : [];
469 $created = wp_insert_term($name, 'product-categories', $args);
470 if (!is_wp_error($created)) {
471 $parentId = (int) $created['term_id'];
472 }
473 }
474 }
475
476 // Assign the leaf term (deepest in the chain)
477 if ($parentId) {
478 $termIds[] = $parentId;
479 }
480 }
481
482 $termIds = array_unique($termIds);
483 if (!empty($termIds)) {
484 wp_set_post_terms($postId, $termIds, 'product-categories');
485 }
486 }
487
488 /**
489 * Sanitize a price value to ensure it's a valid integer (cents).
490 */
491 protected function sanitizePrice($value): int
492 {
493 if (is_numeric($value)) {
494 return absint(round(floatval($value) * 100));
495 }
496
497 return 0;
498 }
499
500 /**
501 * Normalize media array — uploaded attachments keep their id, external URLs get id 0.
502 *
503 * @return array
504 */
505 protected function normalizeMedia(array $media): array
506 {
507 $result = [];
508
509 foreach ($media as $item) {
510 if (empty($item) || !is_array($item)) {
511 continue;
512 }
513
514 $url = sanitize_url(Arr::get($item, 'url', ''));
515 if (empty($url)) {
516 continue;
517 }
518
519 $result[] = [
520 'id' => absint(Arr::get($item, 'id', 0)),
521 'url' => $url,
522 'title' => sanitize_text_field(Arr::get($item, 'title', '')),
523 ];
524 }
525
526 return $result;
527 }
528 }
529