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

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

1,156 lines 54.8 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\ProductMetaResource;
6 use FluentCart\App\Events\ProductVariationsChanged;
7 use FluentCart\App\Helpers\Helper;
8 use FluentCart\App\Helpers\ProductAdminHelper;
9 use FluentCart\App\Models\AttributeRelation;
10 use FluentCart\App\Models\AttributeTerm;
11 use FluentCart\App\Models\ProductDetail;
12 use FluentCart\App\Models\ProductVariation;
13 use FluentCart\Framework\Support\Arr;
14
15 class AdvancedVariationService
16 {
17 /**
18 * Hard ceiling on the number of variant combinations a single save may
19 * generate. The cartesian product of attribute groups grows multiplicatively
20 * (3 groups × 10 terms each = 1,000 variants, 4 × 10 = 10,000), so without
21 * a cap a careless attribute set can blow up admin save time, memory,
22 * and downstream DB write volume. 500 is generous for real e-commerce use
23 * (typical: <50 combinations) while still bounding the worst case.
24 * Filterable so a high-SKU store can raise it after a deliberate review.
25 */
26 const DEFAULT_MAX_COMBINATIONS = 500;
27
28 /**
29 * Hard ceiling on the number of attribute groups a single save may
30 * reference. Caps the cartesian's depth AND bounds the length of the
31 * variation_identifier string (joined term IDs — the column is
32 * VARCHAR(100) on free, so ~5 BIGINT-sized IDs is the schema limit
33 * anyway). Without this, a payload with many single-term groups would
34 * pass the combination cap but still drive an unbounded identifier
35 * concatenation, whereIn list, and relation insert per variant.
36 */
37 const DEFAULT_MAX_GROUPS_PER_SAVE = 10;
38
39 /**
40 * Hard ceiling on the total unique term IDs the payload may reference
41 * (sum across all groups, deduplicated). Bounds the size of the
42 * findMissingTermIds whereIn lookup and the per-variant relation
43 * insert volume even when the cartesian cap is satisfied (the
44 * single-term-many-groups bypass the bot flagged).
45 */
46 const DEFAULT_MAX_TERMS_PER_SAVE = 500;
47
48 /**
49 * Matches fc_product_variations.variation_identifier (VARCHAR(100) on
50 * free). Used by the runtime guard in syncVariantCombinations to abort
51 * the save if any cartesian combination's underscore-joined term IDs
52 * would overflow the column. At the group cap (10) with realistic
53 * 5-8 digit term IDs we land at ~89 chars; the guard exists to catch
54 * the pathological case of growth-rotated BIGINT IDs in long-lived
55 * stores (>10-digit IDs aren't unprecedented). Non-STRICT MySQL would
56 * silently truncate, creating duplicate variants on different
57 * cartesian combinations that collapse to the same identifier prefix.
58 */
59 const VARIATION_IDENTIFIER_MAX_LENGTH = 100;
60
61 /**
62 * Baseline keys every variant's `other_info` carries — mirrors the
63 * payload that ProductBaseModel.addDummyVariant ships for simple /
64 * simple_variations products. The advanced-variation create path used
65 * to write only `{"variant": [...]}`, so downstream consumers that
66 * read `other_info.payment_type` (admin order update, cart line
67 * generation, OrderItemResource) got NULL and tripped the
68 * fct_order_items.payment_type NOT NULL constraint. Keep the keys in
69 * sync with the JS factory so the two surfaces don't drift.
70 */
71 private static function defaultVariantOtherInfo(): array
72 {
73 return [
74 'description' => '',
75 'payment_type' => 'onetime',
76 'tax_class' => 'standard',
77 'tax_exempt' => 'no',
78 'tax_inclusion' => '',
79 'times' => '',
80 'repeat_interval' => 'yearly',
81 'billing_summary' => '',
82 'manage_setup_fee' => 'no',
83 'signup_fee_name' => '',
84 'signup_fee' => '',
85 'setup_fee_per_item' => 'no',
86 'package_slug' => '',
87 'weight' => null,
88 ];
89 }
90
91 public static function syncVariantOption(int $productId, array $data): array
92 {
93 $settings = Arr::get($data, 'options');
94 $srcPricing = ProductDetail::where('post_id', $productId)->first();
95
96 if (!$srcPricing) {
97 return [
98 'message' => __('Product details not found.', 'fluent-cart'),
99 ];
100 }
101
102 // Cap the cartesian explosion BEFORE any DB work. Compute the
103 // projected combination count from the raw payload — multiplying
104 // the size of each non-empty group — and reject early if it exceeds
105 // the configured maximum. Without this guard, a 5×10 attribute set
106 // would attempt to materialise 100,000 variants per save: minutes
107 // of CPU, hundreds of MB of arrays, and a request that never
108 // returns. The check is intentionally on the projection, not on
109 // the actual generated set, so we reject before generateVariationSets()
110 // ever runs.
111 //
112 // Whitelist-sanitize the payload BEFORE every downstream consumer
113 // (cap checks, validation, storage) sees it. The raw $settings is
114 // caller-controlled and lands in ProductDetail.other_info.attribute_config
115 // via the save() below; without filtering, a payload like
116 // [{"variants": [...], "price_override": 999, "_debug": "..."}]
117 // would persist arbitrary keys into other_info — polluting the
118 // canonical store and giving downstream consumers an attack surface
119 // that bypasses every layer above. Only known-good keys (variants,
120 // group_id) survive; everything else is dropped.
121 $settingsArr = self::sanitizeSettings($settings);
122
123 // Shape caps — bound the payload dimensions BEFORE the combination
124 // cap below. Without these, a payload with many single-term groups
125 // (e.g. 1000 groups × 1 term each = 1 variant) would slip past
126 // the combination cap and still drive an unbounded variation_identifier
127 // string, term-validation whereIn list, and per-variant relation
128 // insert count. The combination cap only bounds the cartesian
129 // product; these caps bound the per-variant cost.
130 $maxGroups = (int) apply_filters(
131 'fluent_cart/advanced_variation/max_groups_per_save',
132 self::DEFAULT_MAX_GROUPS_PER_SAVE
133 );
134 $groupCount = self::countNonEmptyGroups($settingsArr);
135 if ($groupCount > $maxGroups) {
136 return [
137 'message' => sprintf(
138 /* translators: 1: provided group count, 2: configured maximum */
139 __('Too many attribute groups (%1$d). The maximum allowed is %2$d per save. Reduce the number of groups, or raise the limit via the fluent_cart/advanced_variation/max_groups_per_save filter.', 'fluent-cart'),
140 $groupCount,
141 $maxGroups
142 ),
143 ];
144 }
145
146 $maxTerms = (int) apply_filters(
147 'fluent_cart/advanced_variation/max_terms_per_save',
148 self::DEFAULT_MAX_TERMS_PER_SAVE
149 );
150 $uniqueTermCount = self::countUniqueTermIds($settingsArr);
151 if ($uniqueTermCount > $maxTerms) {
152 return [
153 'message' => sprintf(
154 /* translators: 1: total unique term count in payload, 2: configured maximum */
155 __('Too many attribute terms (%1$d) referenced. The maximum allowed is %2$d unique terms per save. Trim the term selection, or raise the limit via the fluent_cart/advanced_variation/max_terms_per_save filter.', 'fluent-cart'),
156 $uniqueTermCount,
157 $maxTerms
158 ),
159 ];
160 }
161
162 $maxCombinations = (int) apply_filters(
163 'fluent_cart/advanced_variation/max_combinations',
164 self::DEFAULT_MAX_COMBINATIONS
165 );
166 $projected = self::projectCombinationCount($settingsArr);
167 if ($projected > $maxCombinations) {
168 return [
169 'message' => sprintf(
170 /* translators: 1: projected combination count, 2: configured maximum */
171 __('Too many variation combinations (%1$d). The maximum allowed is %2$d. Reduce attribute groups or terms, or raise the limit via the fluent_cart/advanced_variation/max_combinations filter.', 'fluent-cart'),
172 $projected,
173 $maxCombinations
174 ),
175 ];
176 }
177
178 // Validate that every term ID in the payload actually exists in
179 // fct_atts_terms BEFORE any variant is created. Without this guard,
180 // a payload referencing an unknown term ID (forged, stale UI, race
181 // with a delete) would still create variants for combinations
182 // containing that ID — the relation insert silently skips the
183 // unknown term (since $termMap->get() returns null), leaving an
184 // orphan variant row with incomplete attr_map. Reject the whole
185 // request so the caller fixes the payload before any DB write.
186 $missingTerms = self::findMissingTermIds($settingsArr);
187 if (!empty($missingTerms)) {
188 return [
189 'message' => sprintf(
190 /* translators: 1: comma-separated list of unknown attribute term IDs */
191 __('Unknown attribute terms in payload: %1$s. Refresh the editor and try again.', 'fluent-cart'),
192 implode(', ', array_map('intval', $missingTerms))
193 ),
194 ];
195 }
196
197 // Wrap the variant + relation sync AND the ProductDetail save in a
198 // single transaction. Without this, a failure on the final save
199 // would leave variants and relations on the new attribute_config
200 // while ProductDetail.other_info still points at the old config —
201 // exactly the inconsistent state the admin UI cannot recover from.
202 $db = ProductDetail::query()->getConnection();
203 $db->beginTransaction();
204 try {
205 // Re-fetch with lockForUpdate so two parallel saves on the
206 // same product serialize on this row. Without the lock,
207 // both saves would each call syncVariantCombinations and
208 // both write variants — fct_product_variations.variation_identifier
209 // has no UNIQUE constraint, so the duplicates land cleanly,
210 // creating two rows for every cartesian combination.
211 $srcPricing = ProductDetail::query()
212 ->where('post_id', $productId)
213 ->lockForUpdate()
214 ->first();
215
216 if (!$srcPricing) {
217 $db->rollBack();
218 return [
219 'message' => __('Product details not found.', 'fluent-cart'),
220 ];
221 }
222
223 // Pass $settingsArr (already validated to be an array by the cap
224 // checks above) rather than the raw $settings — if the caller
225 // omitted the 'options' key entirely, $settings is null and the
226 // foreach inside syncVariantCombinations would warn before the
227 // empty-cartesian short-circuit caught it.
228 $syncResult = self::syncVariantCombinations($srcPricing, $settingsArr);
229 $variants = $syncResult['variants'];
230
231 $existingOtherInfo = $srcPricing->other_info;
232 if (!is_array($existingOtherInfo)) {
233 $existingOtherInfo = [];
234 }
235 // Store the DB-DERIVED real-group structure as attribute_config,
236 // not the raw caller payload. A spoofed payload that claimed
237 // wrong group_ids would otherwise persist its lie into other_info
238 // even though the cartesian (and the variant.attr_map relations)
239 // already reflect DB truth. Reading attribute_config later must
240 // give the same answer as reading the relations — they're two
241 // views of the same canonical fact.
242 $existingOtherInfo['attribute_config'] = $syncResult['attribute_config'];
243
244 $srcPricing->fill([
245 'other_info' => $existingOtherInfo,
246 'variation_type' => Helper::PRODUCT_TYPE_ADVANCE_VARIATION,
247 ])->save();
248
249 $db->commit();
250 } catch (\RuntimeException $e) {
251 $db->rollBack();
252 // RuntimeException is OUR validation surface — every throw in
253 // syncVariantCombinations (cap exceeded, identifier overflow,
254 // term-drift during sync, etc.) is an actionable, merchant-safe
255 // message we authored ourselves. Surface it so the caller knows
256 // which limit they hit and how to fix it. Distinct from the
257 // generic Throwable catch below which suppresses driver-level
258 // messages that could leak query fragments / file paths.
259 return [
260 'message' => $e->getMessage(),
261 ];
262 } catch (\Throwable $e) {
263 $db->rollBack();
264 // Non-RuntimeException — driver errors, unexpected fatals, etc.
265 // Intentionally do NOT include $e->getMessage() — could leak
266 // query fragments, file paths, and column names to the API
267 // client. Matches the AttrGroupResource / AttrTermResource
268 // catch-block pattern from the Attributes module.
269 return [
270 'message' => __('Failed to update variation combination.', 'fluent-cart'),
271 ];
272 }
273
274 // Mirror the free-side canonical variant-update event so cache
275 // invalidators, search indexers, audit loggers, and webhook
276 // subscribers listening on this hook see our cartesian writes too.
277 // Free fires this from ProductResource.php after its non-advanced
278 // variant batchUpdate; without firing it here, the advanced-variation
279 // path becomes a silent fork of the standard variant-save flow.
280 do_action('fluent_cart/product/variants_updated', [
281 'post_id' => $productId,
282 'variants' => $variants ? $variants->toArray() : [],
283 ]);
284
285 // Re-resolve default_variation_id against the new combination set —
286 // generate / reorder / add / update / remove all land here. The default
287 // is always the first combination by serial_index (UpdateDefaultVariation,
288 // this event's listener), so reordering redefines it and it ignores
289 // stock/active state.
290 (new ProductVariationsChanged([$productId]))->dispatch();
291
292 return [
293 'message' => __('Variation combination updated!', 'fluent-cart'),
294 'data' => $variants,
295 ];
296 }
297
298 private static function syncVariantCombinations($srcDetails, $variations)
299 {
300 // Extract every term ID from the payload — flat, unique, positive only.
301 // The cartesian dimensions below are derived from the DB-truth grouping
302 // of these IDs, NOT from the caller-claimed payload structure. This is
303 // the canonical fix for the trust attack: a payload that claims
304 // [{group_id: 99, variants: [10]}, {group_id: 100, variants: [20]}]
305 // when terms 10 and 20 actually both live in group 5 would otherwise
306 // produce a 2-dimensional cartesian (instead of the 1 real dimension)
307 // and generate variants claiming two terms from group 5 each. Build
308 // dimensions from $termMap.group_id below instead — payload group_id
309 // becomes pure display metadata.
310 $payloadTermIds = [];
311 // First-appearance rank of each term id across the payload. The merchant
312 // controls this order by drag-reordering the option *values* inside a
313 // card; it drives the within-group term order of the cartesian below and
314 // therefore the serial_index of each generated combination. This is the
315 // product-level order (persisted in other_info.attribute_config), NOT the
316 // library-wide fct_atts_terms.serial — reordering here never affects any
317 // other product using the same attribute group.
318 $payloadTermOrder = [];
319 foreach ($variations as $variation) {
320 if (empty($variation['variants']) || !is_array($variation['variants'])) {
321 continue;
322 }
323 foreach ($variation['variants'] as $termId) {
324 $id = (int) $termId;
325 if ($id > 0) {
326 $payloadTermIds[$id] = true;
327 if (!isset($payloadTermOrder[$id])) {
328 $payloadTermOrder[$id] = count($payloadTermOrder);
329 }
330 }
331 }
332 }
333 $payloadTermIds = array_keys($payloadTermIds);
334
335 $srcDetails->load('product');
336 // Defensive: $srcDetails->product can be null if the underlying
337 // wp_posts row was deleted between the ProductDetail lookup and
338 // here. Fall back to an empty title rather than fatal — the
339 // variant rows will still be valid, the merchant can fix the
340 // title on next save.
341 $variationTitle = $srcDetails->product ? $srcDetails->product->post_title : '';
342 $variantIds = [];
343
344 // Empty payload means the merchant cleared the variation set —
345 // ProductAdminHelper::deleteOrphanVariant with an empty keep-list
346 // removes every variant on the product. Return early; everything
347 // below would no-op anyway.
348 if (empty($payloadTermIds)) {
349 ProductAdminHelper::deleteOrphanVariant($srcDetails->post_id, []);
350 return [
351 'variants' => new \FluentCart\Framework\Support\Collection(),
352 'attribute_config' => [],
353 ];
354 }
355
356 // Load the referenced terms with lockForUpdate — locks all referenced
357 // term rows for the duration of this transaction. AttributeRelation
358 // has no FK on term_id, so a parallel DELETE that beats our SELECT
359 // would otherwise leave the bulk insert below pointing at a stale
360 // term_id (a permanent orphan). The pre-flight findMissingTermIds in
361 // syncVariantOption already filters unknown IDs before the txn opens;
362 // this lock + the count check below close the TOCTOU window.
363 $termMap = AttributeTerm::query()
364 ->whereIn('id', $payloadTermIds)
365 ->lockForUpdate()
366 ->get()
367 ->keyBy('id');
368
369 if (count($termMap) !== count($payloadTermIds)) {
370 throw new \RuntimeException(
371 'Attribute terms changed during sync — aborting to avoid orphan variant.'
372 );
373 }
374
375 // DERIVE cartesian dimensions from REAL term groups (DB-truth). The
376 // payload's group_id claims are pure display metadata — the actual
377 // structure comes from term.group_id in fct_atts_terms. This means
378 // a spoofed payload (e.g. claiming term 10 is in group 99 when it
379 // really lives in group 5) gets remapped to its real group before
380 // the cartesian runs. Same protection for the case where a payload
381 // splits same-group terms across multiple "entries" or merges
382 // different-group terms into one "entry": the real grouping wins.
383 $realGroupedTerms = [];
384 foreach ($termMap as $term) {
385 $realGroupedTerms[(int) $term->group_id][(int) $term->id] = true;
386 }
387 $realGroups = [];
388 foreach ($realGroupedTerms as $gid => $tidSet) {
389 $tids = array_keys($tidSet);
390 // Order each group's terms by the merchant's payload order so a
391 // drag-reorder of values changes the cartesian iteration order —
392 // and therefore each combination's serial_index — for THIS product
393 // only. Terms with no payload rank (can't normally happen, since the
394 // cartesian terms ARE the payload terms) sort to the tail in numeric
395 // id order so the result stays deterministic.
396 usort($tids, function ($a, $b) use ($payloadTermOrder) {
397 $rankA = $payloadTermOrder[$a] ?? PHP_INT_MAX;
398 $rankB = $payloadTermOrder[$b] ?? PHP_INT_MAX;
399 if ($rankA === $rankB) {
400 return $a <=> $b;
401 }
402 return $rankA <=> $rankB;
403 });
404 $realGroups[$gid] = $tids;
405 }
406 ksort($realGroups);
407
408 // The cartesian CONTENT is DB-derived above (anti-spoof). The cartesian
409 // ORDER, though, is display metadata the merchant controls by drag-
410 // reordering the option cards — ksort alone forces group-id order and
411 // silently discards that choice. Re-order $realGroups to follow the
412 // sequence the caller's option entries appear in: map each entry to the
413 // real group of its first term, dedupe, and lead with that order. Real
414 // groups no entry references (spoofed/remapped terms) keep the ksort'd
415 // tail. Identifiers stay deterministic regardless — each variant's term
416 // ids are asort'd numerically below, independent of group order.
417 $callerGroupOrder = [];
418 foreach ($variations as $variation) {
419 if (empty($variation['variants']) || !is_array($variation['variants'])) {
420 continue;
421 }
422 foreach ($variation['variants'] as $termId) {
423 $term = $termMap->get((int) $termId);
424 if ($term) {
425 $realGid = (int) $term->group_id;
426 if (!in_array($realGid, $callerGroupOrder, true)) {
427 $callerGroupOrder[] = $realGid;
428 }
429 break;
430 }
431 }
432 }
433 $orderedRealGroups = [];
434 foreach ($callerGroupOrder as $gid) {
435 if (isset($realGroups[$gid])) {
436 $orderedRealGroups[$gid] = $realGroups[$gid];
437 }
438 }
439 foreach ($realGroups as $gid => $tids) {
440 if (!isset($orderedRealGroups[$gid])) {
441 $orderedRealGroups[$gid] = $tids;
442 }
443 }
444 $realGroups = $orderedRealGroups;
445
446 // Re-cap on the DERIVED structure. The payload-time caps in
447 // syncVariantOption used caller-supplied dimensions, which can
448 // diverge from reality after regrouping by DB truth (e.g. a
449 // payload may bundle terms from many real groups into one entry,
450 // making the real group count exceed the cap even though the
451 // payload entry count didn't). Re-apply both caps here.
452 $maxGroups = (int) apply_filters(
453 'fluent_cart/advanced_variation/max_groups_per_save',
454 self::DEFAULT_MAX_GROUPS_PER_SAVE
455 );
456 if (count($realGroups) > $maxGroups) {
457 throw new \RuntimeException(sprintf(
458 'Real attribute group count (%d) exceeds the limit of %d.',
459 count($realGroups),
460 $maxGroups
461 ));
462 }
463 $maxCombinations = (int) apply_filters(
464 'fluent_cart/advanced_variation/max_combinations',
465 self::DEFAULT_MAX_COMBINATIONS
466 );
467 $realProjection = 1;
468 foreach ($realGroups as $tids) {
469 $realProjection *= count($tids);
470 if ($realProjection > PHP_INT_MAX / 1000) {
471 $realProjection = PHP_INT_MAX;
472 break;
473 }
474 }
475 if ($realProjection > $maxCombinations) {
476 throw new \RuntimeException(sprintf(
477 'Real cartesian combination count (%d) exceeds the limit of %d.',
478 $realProjection,
479 $maxCombinations
480 ));
481 }
482
483 // Generate the cartesian from REAL groups. Identifiers are made
484 // deterministic by the per-variant asort below, independent of the
485 // group order, so the caller-driven ordering above is display-only.
486 $variants = self::generateVariationSets(array_values($realGroups));
487
488 // Normalize ordering once so identifiers are deterministic across
489 // requests AND so we can preload existing variations in one query
490 // instead of one query per cartesian combination.
491 $normalized = [];
492 foreach ($variants as $index => $variant) {
493 asort($variant, SORT_NUMERIC);
494 $identifier = implode('_', $variant);
495 // Hard guard against silent column truncation. The schema
496 // constant is VARIATION_IDENTIFIER_MAX_LENGTH; without this
497 // check, two distinct cartesian combinations whose joined
498 // IDs differ only past char 100 would collapse to the same
499 // truncated identifier on non-STRICT MySQL, creating
500 // hard-to-debug duplicate variants.
501 if (strlen($identifier) > self::VARIATION_IDENTIFIER_MAX_LENGTH) {
502 throw new \RuntimeException(
503 'Generated variation_identifier exceeds the ' . self::VARIATION_IDENTIFIER_MAX_LENGTH
504 . '-char column limit. Reduce the number of attribute groups or contact support.'
505 );
506 }
507 $normalized[$index] = [
508 'identifier' => $identifier,
509 'variant' => $variant,
510 ];
511 }
512
513 // Preload ALL existing variations for this product with their thumbnails
514 // in one query. Two uses:
515 // (a) exact identifier lookup — same as before (serial_index dirty-check)
516 // (b) ancestor inheritance — when a new attribute group is added, every
517 // variation_identifier changes (new term ID appended), so the exact
518 // lookup finds nothing and all variants would be created at $0 with
519 // no media. The ancestor search below finds the old "Red / S" row as
520 // the parent of the new "Red / S / Cotton" combination and copies its
521 // price, stock, status, and thumbnail so the merchant's pricing work
522 // survives attribute expansion.
523 $allExistingVariations = ProductVariation::query()
524 ->where('post_id', $srcDetails->post_id)
525 ->with(['media'])
526 ->get();
527
528 $existingVariations = $allExistingVariations->keyBy('variation_identifier');
529
530 // Inverted index for the REMOVE-case ancestor search: term_id → [variants].
531 // Built once in O(E) so the per-miss loop only iterates the shortest posting
532 // list instead of the full collection.
533 $termToVariants = [];
534 foreach ($allExistingVariations as $existingVariant) {
535 if (empty($existingVariant->variation_identifier)) {
536 continue;
537 }
538 foreach (explode('_', $existingVariant->variation_identifier) as $termId) {
539 $termToVariants[$termId][] = $existingVariant;
540 }
541 }
542
543 // Rank each group by its position in the current (caller-ordered)
544 // group sequence so composed titles follow the merchant's group order
545 // (Color / Material / Size / Pattern) instead of the per-variant
546 // asort, which is numeric term-id (i.e. creation-time) order. Without
547 // this, reordering groups after variants exist leaves stale titles.
548 $groupOrderRank = [];
549 $nextGroupRank = 0;
550 foreach (array_keys($realGroups) as $orderedGroupId) {
551 $groupOrderRank[(int) $orderedGroupId] = $nextGroupRank++;
552 }
553
554 foreach ($normalized as $index => $row) {
555 $identifier = $row['identifier'];
556 $variant = $row['variant'];
557
558 // Compose the variant title from its terms — "Single Site / 4 GB"
559 // rather than the parent product name shared by every variant.
560 // Used both for new inserts below and the self-heal path on
561 // already-existing variants (pre-fix data still carrying
562 // post_title as their title). Ordered by group rank so the title
563 // tracks the merchant's group order, not numeric term-id order.
564 $termsInGroupOrder = $variant;
565 usort($termsInGroupOrder, function ($leftTermId, $rightTermId) use ($termMap, $groupOrderRank) {
566 $leftTerm = $termMap->get((int) $leftTermId);
567 $rightTerm = $termMap->get((int) $rightTermId);
568 $leftRank = $leftTerm ? ($groupOrderRank[(int) $leftTerm->group_id] ?? PHP_INT_MAX) : PHP_INT_MAX;
569 $rightRank = $rightTerm ? ($groupOrderRank[(int) $rightTerm->group_id] ?? PHP_INT_MAX) : PHP_INT_MAX;
570 return $leftRank <=> $rightRank;
571 });
572 $composedTitleParts = [];
573 foreach ($termsInGroupOrder as $termId) {
574 $term = $termMap->get((int) $termId);
575 if ($term && $term->title !== '') {
576 $composedTitleParts[] = $term->title;
577 }
578 }
579 $composedTitle = $composedTitleParts
580 ? implode(' / ', $composedTitleParts)
581 : $variationTitle;
582
583 $exist = $existingVariations->get($identifier);
584 $newSerialIndex = $index + 1;
585 if ($exist) {
586 // Dirty-check before saving — when the cartesian shape
587 // hasn't changed, every existing variant ends up with the
588 // same serial_index it already has, and a blind save would
589 // fire N UPDATE statements + N model events for nothing
590 // (updated_at would tick even though no field changed).
591 $needsSave = false;
592 if ((int) $exist->serial_index !== $newSerialIndex) {
593 $exist->serial_index = $newSerialIndex;
594 $needsSave = true;
595 }
596 // Self-heal pre-fix rows whose title is still the parent
597 // post_title. Only touch the row when the title looks like
598 // an untouched legacy default — any customized title is
599 // preserved. Skips the wider term-rename propagation (a
600 // separate concern by design).
601 if ($exist->variation_title === $variationTitle
602 && $composedTitle !== $variationTitle
603 ) {
604 $exist->variation_title = $composedTitle;
605 $needsSave = true;
606 } elseif ($composedTitle !== ''
607 && $exist->variation_title !== $composedTitle
608 ) {
609 // Re-order auto-composed titles to follow the merchant's
610 // current group order when groups are reordered after
611 // variants exist. Only touch titles that are still the
612 // term-join in some order — a title the merchant typed has
613 // a different term multiset and is left untouched.
614 $storedTitleParts = array_map('trim', explode(' / ', (string) $exist->variation_title));
615 $sortedStoredParts = $storedTitleParts;
616 $sortedComposedParts = $composedTitleParts;
617 sort($sortedStoredParts);
618 sort($sortedComposedParts);
619 if ($sortedStoredParts === $sortedComposedParts) {
620 $exist->variation_title = $composedTitle;
621 $needsSave = true;
622 }
623 }
624 // Self-heal pre-fix other_info that was written before the
625 // baseline merge above existed — typically only the
626 // `variant` key plus, on duplicated products, a couple of
627 // setup_fee leftovers. Top up missing baseline keys
628 // without disturbing whatever the merchant has customized.
629 $existingOtherInfo = \is_array($exist->other_info) ? $exist->other_info : [];
630 $missingKeys = array_diff_key(self::defaultVariantOtherInfo(), $existingOtherInfo);
631 if (!empty($missingKeys)) {
632 $exist->other_info = array_merge($existingOtherInfo, $missingKeys);
633 $needsSave = true;
634 }
635 if ($needsSave) {
636 $exist->save();
637 }
638 } else {
639 // No exact match — find the closest related variant using two
640 // strategies depending on the reshape direction.
641 //
642 // ADD case (candidate ⊆ new, |candidate| = |new| − 1):
643 // $variant is asort()-ed, so every (k−1)-subset of its term
644 // IDs is also a valid sorted identifier. Try each of the k
645 // subsets as a direct keyed probe into $existingVariations.
646 // O(k) per miss; k ≤ 10 (MAX_GROUPS cap).
647 //
648 // REMOVE case (new ⊆ candidate, |candidate| > |new|):
649 // Use $termToVariants to pick the shortest posting list,
650 // then verify the full-subset condition only for those
651 // candidates. O(E / max_terms_per_group) per miss instead
652 // of O(E).
653 $ancestor = null;
654 $newTermsStr = array_values(array_map('strval', $variant));
655 // $variant is asort()-ed; strval preserves the numeric order.
656
657 // ── ADD ──────────────────────────────────────────────────
658 $kTerms = count($newTermsStr);
659 for ($skip = 0; $skip < $kTerms; $skip++) {
660 $subTerms = $newTermsStr;
661 array_splice($subTerms, $skip, 1);
662 $candidate = $existingVariations->get(implode('_', $subTerms));
663 if ($candidate) {
664 $ancestor = $candidate;
665 break;
666 }
667 }
668
669 // ── REMOVE ───────────────────────────────────────────────
670 if ($ancestor === null) {
671 $newTermCount = $kTerms;
672
673 // Find the posting list with fewest entries.
674 $shortestList = null;
675 $shortestCount = PHP_INT_MAX;
676 foreach ($newTermsStr as $tid) {
677 $list = $termToVariants[$tid] ?? [];
678 if (count($list) < $shortestCount) {
679 $shortestCount = count($list);
680 $shortestList = $list;
681 }
682 }
683
684 if ($shortestList !== null) {
685 $newTermFlip = array_flip($newTermsStr);
686 $bestExtra = PHP_INT_MAX;
687 foreach ($shortestList as $candidate) {
688 $candidateTerms = explode('_', $candidate->variation_identifier);
689 $candidateCount = count($candidateTerms);
690 if ($candidateCount <= $newTermCount) {
691 continue; // must be a strict superset
692 }
693 $extraCount = $candidateCount - $newTermCount;
694 if ($extraCount >= $bestExtra) {
695 continue; // tighter fit already found
696 }
697 $candidateFlip = array_flip($candidateTerms);
698 $allIn = true;
699 foreach ($newTermsStr as $tid) {
700 if (!isset($candidateFlip[$tid])) {
701 $allIn = false;
702 break;
703 }
704 }
705 if ($allIn) {
706 $ancestor = $candidate;
707 $bestExtra = $extraCount;
708 }
709 }
710 unset($newTermFlip);
711 }
712 }
713
714 // Layer other_info so the new variant carries the same
715 // baseline simple variations get, the ancestor's
716 // customizations (payment_type, tax_class, signup_fee, …)
717 // if reshaping the cartesian, and finally the term IDs
718 // for this row. Without the baseline, every downstream
719 // reader that asks for other_info.payment_type would get
720 // NULL and break the OrderItem insert.
721 $ancestorOtherInfo = ($ancestor !== null && \is_array($ancestor->other_info))
722 ? $ancestor->other_info
723 : [];
724 $composedOtherInfo = array_merge(
725 self::defaultVariantOtherInfo(),
726 $ancestorOtherInfo,
727 ['variant' => array_values($variant)]
728 );
729
730 $exist = ProductVariation::create([
731 'post_id' => $srcDetails->post_id,
732 'serial_index' => $newSerialIndex,
733 'item_price' => $ancestor !== null ? (float) $ancestor->item_price : 0,
734 'compare_price' => $ancestor !== null ? (float) $ancestor->compare_price : 0,
735 // New combinations (no ancestor to inherit from) default to
736 // the same starter stock a Simple product's default variant
737 // gets (ProductController::store): in-stock, total_stock 1,
738 // available 1. manage_stock defaults to 0, so these values
739 // only matter once the merchant turns stock management on —
740 // but defaulting to 1 keeps them consistent with simple
741 // variations instead of starting out-of-stock-on-enable.
742 'total_stock' => $ancestor !== null ? (int) $ancestor->total_stock : 1,
743 'available' => $ancestor !== null ? (int) $ancestor->available : 1,
744 'stock_status' => $ancestor !== null ? $ancestor->stock_status : 'in-stock',
745 'item_status' => $ancestor !== null ? $ancestor->item_status : 'active',
746 'fulfillment_type' => $ancestor !== null ? $ancestor->fulfillment_type : $srcDetails->fulfillment_type,
747 'manage_stock' => $ancestor !== null ? $ancestor->manage_stock : $srcDetails->manage_stock,
748 // Advanced-variation missed the payment_type column that
749 // simple / simple_variations set — default it to 'onetime'.
750 'payment_type' => Arr::get($composedOtherInfo, 'payment_type', 'onetime'),
751 'variation_title' => $composedTitle,
752 'variation_identifier' => $identifier,
753 'other_info' => $composedOtherInfo,
754 ]);
755
756 // Copy thumbnail from ancestor if present. Each variant owns
757 // its own ProductMeta row; create() stamps object_type correctly.
758 if ($ancestor !== null
759 && $ancestor->media
760 && is_array($ancestor->media->meta_value)
761 ) {
762 ProductMetaResource::create($ancestor->media->meta_value, ['product_id' => $exist->id]);
763 }
764 }
765
766 $variantIds[] = $exist->id;
767 // Stash the resolved variant id back into the row so the
768 // relations pass below doesn't have to re-resolve.
769 $normalized[$index]['variant_id'] = $exist->id;
770 }
771
772 // Bulk relations: preload every relation row for these variants in
773 // ONE query, compute which (variant_id, term_id) pairs are missing,
774 // insert just those in a single statement. The old per-term
775 // firstOrCreate ran 2 queries × N terms × M variants — at 5 groups
776 // × 4 terms × 10 variants that was 400 queries; this is 2.
777 $existingKeys = [];
778 if ($variantIds) {
779 $existingRelations = AttributeRelation::query()
780 ->whereIn('object_id', $variantIds)
781 ->get();
782 foreach ($existingRelations as $rel) {
783 $existingKeys[$rel->object_id . ':' . $rel->term_id] = true;
784 }
785 }
786
787 // Bulk insert() bypasses Eloquent auto-timestamps, so stamp them
788 // explicitly. Schema is `created_at DATETIME NULL` but every other
789 // table in the codebase has stamped timestamps for forensics.
790 $now = gmdate('Y-m-d H:i:s');
791 $relationRows = [];
792 foreach ($normalized as $row) {
793 $variantId = $row['variant_id'];
794 foreach ($row['variant'] as $termId) {
795 $term = $termMap->get($termId);
796 if (!$term) {
797 continue;
798 }
799 $key = $variantId . ':' . $termId;
800 if (isset($existingKeys[$key])) {
801 continue;
802 }
803 $relationRows[] = [
804 'object_id' => $variantId,
805 'term_id' => (int) $termId,
806 'group_id' => (int) $term->group_id,
807 'created_at' => $now,
808 'updated_at' => $now,
809 ];
810 // Mark in-memory so a duplicate row within this batch
811 // (shouldn't happen, but cartesian + bad input could)
812 // doesn't trip the composite UNIQUE.
813 $existingKeys[$key] = true;
814 }
815 }
816
817 if ($relationRows && !AttributeRelation::insert($relationRows)) {
818 throw new \RuntimeException('AttributeRelation::insert returned false during variant sync.');
819 }
820
821 // Permanently deletes variant rows (stock, pricing) not in $variantIds — removing an attribute from the config is irreversible.
822 ProductAdminHelper::deleteOrphanVariant($srcDetails->post_id, $variantIds);
823
824 // Build the DB-derived attribute_config to hand back for storage —
825 // one entry per real group (caller-ordered above) with deduped term
826 // IDs. The shape matches what sanitizeSettings produced for the
827 // happy path (group_id + variants), so the editor reading back
828 // sees a familiar structure, just normalized.
829 $attributeConfig = [];
830 foreach ($realGroups as $gid => $tids) {
831 $attributeConfig[] = [
832 'group_id' => $gid,
833 'variants' => $tids,
834 ];
835 }
836
837 // Eager-load media + attrMap so the POST response carries everything
838 // the admin table needs to render immediately. The editor optimistically
839 // swaps these variants into the reactive store and holds its skeleton
840 // until a variant has an attr_map (so the grouped table can bucket rows
841 // instead of flashing the flat fallback). Without attrMap here that
842 // condition is only met after the fire-and-forget reloader() round-trip,
843 // so the skeleton lingered up to its 2.5s timeout after the save already
844 // succeeded. Loading the relation lets the skeleton clear in the same
845 // frame as the optimistic swap.
846 return [
847 // Order by serial_index so the POST response the editor optimistically
848 // swaps in already matches the merchant's term/group order — same
849 // ordering the variants() relation (ProductDetail / Product) applies on
850 // the reloader round-trip, so the table doesn't flash id-order first.
851 'variants' => ProductVariation::query()
852 ->whereIn('id', $variantIds)
853 ->with(['media', 'attrMap'])
854 ->orderBy('serial_index', 'asc')
855 ->get(),
856 'attribute_config' => $attributeConfig,
857 ];
858 }
859
860 /**
861 * Whitelist-sanitize the caller-supplied options payload. Strips any
862 * keys we don't explicitly recognise so a hostile payload cannot pollute
863 * ProductDetail.other_info.attribute_co
864 * nfig with arbitrary structure.
865 * Within each group entry, only 'variants' (term IDs) and 'group_id'
866 * survive — both coerced to ints. Empty / non-array inputs become [].
867 *
868 * Runs BEFORE the cap checks so they operate on the cleaned shape,
869 * and BEFORE the storage write so what lands in other_info matches
870 * what the rest of the codebase reads back.
871 */
872 private static function sanitizeSettings($settings): array
873 {
874 if (!is_array($settings)) {
875 return [];
876 }
877
878 // First pass — normalize each entry to {variants: int[], group_id?: int}.
879 $entries = [];
880 foreach ($settings as $variation) {
881 if (!is_array($variation)) {
882 continue;
883 }
884 $entry = [];
885 if (isset($variation['variants']) && is_array($variation['variants'])) {
886 $ids = [];
887 foreach ($variation['variants'] as $termId) {
888 $id = (int) $termId;
889 if ($id > 0) {
890 $ids[] = $id;
891 }
892 }
893 $entry['variants'] = array_values(array_unique($ids));
894 }
895 if (isset($variation['group_id'])) {
896 $gid = (int) $variation['group_id'];
897 // Drop zero/negative — group_id is just metadata for round-trip
898 // display, but a 0/-N value passing through into other_info
899 // would land as a garbage key in the stored attribute_config.
900 if ($gid > 0) {
901 $entry['group_id'] = $gid;
902 }
903 }
904 // Skip entries with no usable terms — the cartesian would
905 // ignore them anyway, no point polluting the stored config.
906 if (!empty($entry['variants'])) {
907 $entries[] = $entry;
908 }
909 }
910
911 // Second pass — merge entries that share the same group_id. A payload
912 // like [{group_id:5, variants:[10]}, {group_id:5, variants:[20]}]
913 // describes ONE attribute group with two selected terms (10 AND 20),
914 // not two separate group dimensions in the cartesian. Without this
915 // merge, the cartesian generates variants with TWO terms from the
916 // same group each — a single variant claiming to be both "Color:Red"
917 // AND "Color:Blue" simultaneously. AttributeRelation has a composite
918 // UNIQUE on (object_id, group_id, term_id) that lets both rows in,
919 // so the storage corruption is silent. Same pattern the bot hit on
920 // round 9 for term-within-group dedup — one level up.
921 $byGroupId = [];
922 $ungrouped = [];
923 foreach ($entries as $entry) {
924 if (isset($entry['group_id'])) {
925 $gid = $entry['group_id'];
926 if (isset($byGroupId[$gid])) {
927 $byGroupId[$gid]['variants'] = array_values(array_unique(
928 array_merge($byGroupId[$gid]['variants'], $entry['variants'])
929 ));
930 } else {
931 $byGroupId[$gid] = $entry;
932 }
933 } else {
934 // Entries without group_id can't be deduped by it — pass
935 // through as distinct cartesian dimensions. A well-formed
936 // editor payload always includes group_id; missing-group_id
937 // is a defensive case for older client builds.
938 $ungrouped[] = $entry;
939 }
940 }
941
942 return array_merge(array_values($byGroupId), $ungrouped);
943 }
944
945 /**
946 * Returns the term IDs from the payload that don't exist in fct_atts_terms.
947 * Called by syncVariantOption to reject the whole request when ANY term
948 * reference is unknown — better than silently creating orphan variants
949 * with missing relation rows (the bot-flagged failure mode).
950 *
951 * Returns empty array when every referenced term resolves. The single
952 * whereIn query is bounded by the projected-combination cap above, so
953 * worst case is one indexed PK lookup of <= MAX_COMBINATIONS unique IDs.
954 */
955 private static function findMissingTermIds(array $variations): array
956 {
957 $termIds = [];
958 foreach ($variations as $variation) {
959 $variants = Arr::get($variation, 'variants', []);
960 if (!is_array($variants)) {
961 continue;
962 }
963 foreach ($variants as $termId) {
964 $id = (int) $termId;
965 if ($id > 0) {
966 $termIds[] = $id;
967 }
968 }
969 }
970 $termIds = array_values(array_unique($termIds));
971
972 if (empty($termIds)) {
973 return [];
974 }
975
976 $found = AttributeTerm::query()
977 ->whereIn('id', $termIds)
978 ->pluck('id')
979 ->map('intval')
980 ->all();
981
982 return array_values(array_diff($termIds, $found));
983 }
984
985 /**
986 * Number of non-empty groups in the payload. Empty groups are skipped
987 * because they neither contribute to the cartesian nor to the cost
988 * caps below. Bounds the depth of the cartesian AND the length of the
989 * variation_identifier string (joined term IDs per variant).
990 */
991 private static function countNonEmptyGroups(array $variations): int
992 {
993 $count = 0;
994 foreach ($variations as $variation) {
995 $terms = Arr::get($variation, 'variants', []);
996 if (is_array($terms) && !empty($terms)) {
997 $count++;
998 }
999 }
1000 return $count;
1001 }
1002
1003 /**
1004 * Total unique term IDs referenced across all groups (positive ints
1005 * only). Bounds the size of the term-validation whereIn and the
1006 * per-save relation-insert volume — independently of the combination
1007 * cap, which can be satisfied even with many single-term groups.
1008 */
1009 private static function countUniqueTermIds(array $variations): int
1010 {
1011 $ids = [];
1012 foreach ($variations as $variation) {
1013 $terms = Arr::get($variation, 'variants', []);
1014 if (!is_array($terms)) {
1015 continue;
1016 }
1017 foreach ($terms as $termId) {
1018 $id = (int) $termId;
1019 if ($id > 0) {
1020 $ids[$id] = true;
1021 }
1022 }
1023 }
1024 return count($ids);
1025 }
1026
1027 /**
1028 * Cheap O(N) projection of the cartesian combination count from the raw
1029 * payload — multiplies the term count of every non-empty group. Used by
1030 * the cap guard in syncVariantOption so we reject blow-up payloads
1031 * BEFORE generateVariationSets() materialises the actual combinations.
1032 *
1033 * Returns 0 when every group is empty (no variants will be generated)
1034 * so the cap check treats that as a no-op, not a violation.
1035 */
1036 private static function projectCombinationCount(array $variations): int
1037 {
1038 $product = 1;
1039 $sawAny = false;
1040 foreach ($variations as $variation) {
1041 $terms = Arr::get($variation, 'variants', []);
1042 if (!is_array($terms) || empty($terms)) {
1043 continue;
1044 }
1045 $sawAny = true;
1046 $product *= count($terms);
1047 // Bail early once we've already blown past any reasonable cap —
1048 // no need to keep multiplying once we're in the tens of millions.
1049 if ($product > PHP_INT_MAX / 1000) {
1050 return PHP_INT_MAX;
1051 }
1052 }
1053 return $sawAny ? $product : 0;
1054 }
1055
1056 private static function generateVariationSets(array $groups): array
1057 {
1058 if (empty($groups)) {
1059 return [];
1060 }
1061
1062 $result = [[]];
1063
1064 foreach ($groups as $group) {
1065 $expanded = [];
1066 foreach ($result as $existing) {
1067 foreach ($group as $item) {
1068 $expanded[] = array_merge($existing, [$item]);
1069 }
1070 }
1071 $result = $expanded;
1072 }
1073
1074 return $result;
1075 }
1076
1077 /**
1078 * Attaches term + group display fields to every variant.attr_map row on
1079 * the given product payload. Runs once per single-product API response
1080 * (scoped by the variation_type guard in AdvancedVariation::register).
1081 * Eager-loads terms with their group in ONE query so a product with N
1082 * variants is still one query, not N.
1083 *
1084 * Returns the original product array unchanged when no terms are
1085 * referenced — list-endpoint responses or products with no advanced
1086 * variation pay no query cost.
1087 */
1088 public static function hydrateProductData(array $product): array
1089 {
1090 $variants = Arr::get($product, 'variants', []);
1091
1092 $termIds = [];
1093 foreach ($variants as $variant) {
1094 foreach (Arr::get($variant, 'attr_map', []) as $relation) {
1095 $termId = (int) Arr::get($relation, 'term_id');
1096 if ($termId) {
1097 $termIds[] = $termId;
1098 }
1099 }
1100 }
1101 $termIds = array_unique($termIds);
1102
1103 if (empty($termIds)) {
1104 return $product;
1105 }
1106
1107 $terms = AttributeTerm::query()
1108 ->whereIn('id', $termIds)
1109 ->with('group')
1110 ->get()
1111 ->keyBy('id');
1112
1113 // Rank groups by the merchant's current order (other_info.attribute_config)
1114 // so the storefront selector / labels follow the same Color / Material /
1115 // Size / Pattern order as the editor — not the relation insertion order,
1116 // which is frozen at creation time and ignores later drag-drop reorders.
1117 $groupOrderRank = [];
1118 $nextGroupRank = 0;
1119 foreach (Arr::get($product, 'detail.other_info.attribute_config', []) as $groupConfig) {
1120 $groupId = (int) Arr::get($groupConfig, 'group_id');
1121 if ($groupId && !isset($groupOrderRank[$groupId])) {
1122 $groupOrderRank[$groupId] = $nextGroupRank++;
1123 }
1124 }
1125
1126 foreach ($variants as $index => $variant) {
1127 $mapped = [];
1128 foreach (Arr::get($variant, 'attr_map', []) as $relation) {
1129 $termId = (int) Arr::get($relation, 'term_id');
1130 $term = $terms->get($termId);
1131 if (!$term) {
1132 continue;
1133 }
1134 $mapped[] = [
1135 'term_id' => $termId,
1136 'title' => $term->title,
1137 'slug' => $term->slug,
1138 'group_id' => $term->group_id,
1139 'group_title' => $term->group ? $term->group->title : '',
1140 'group_slug' => $term->group ? $term->group->slug : '',
1141 ];
1142 }
1143 if ($groupOrderRank) {
1144 usort($mapped, function ($leftAttr, $rightAttr) use ($groupOrderRank) {
1145 $leftRank = $groupOrderRank[(int) $leftAttr['group_id']] ?? PHP_INT_MAX;
1146 $rightRank = $groupOrderRank[(int) $rightAttr['group_id']] ?? PHP_INT_MAX;
1147 return $leftRank <=> $rightRank;
1148 });
1149 }
1150 $product['variants'][$index]['attr_map'] = $mapped;
1151 }
1152
1153 return $product;
1154 }
1155 }
1156