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.6 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 All 49 releases
fluent-cart / app / Services / BulkProductInsertService.php

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

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