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 / BulkProductInsertService.php

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

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