| @@ -5,12 +5,14 @@ | ||
| 5 | 5 | use FluentCart\Api\Resource\ProductDownloadResource; |
| 6 | 6 | use FluentCart\Api\Resource\ProductVariationResource; |
| 7 | 7 | use FluentCart\App\Models\AttributeRelation; |
| 8 | 8 | use FluentCart\App\Models\AttributeTerm; |
| 9 | +use FluentCart\App\Models\ProductMeta; | |
| 9 | 10 | use FluentCart\App\Models\ProductVariation; |
| 10 | 11 | use FluentCart\App\Services\Helpers; |
| 11 | 12 | use FluentCart\App\Vite; |
| 12 | 13 | use FluentCart\Framework\Support\Arr; |
| 14 | +use FluentCart\Framework\Support\Collection; | |
| 13 | 15 | |
| 14 | 16 | class ProductAdminHelper |
| 15 | 17 | { |
| 16 | 18 | |
| @@ -72,117 +74,218 @@ | ||
| 72 | 74 | } |
| 73 | 75 | |
| 74 | 76 | $variants = self::generateVariationSets($formattedVariations); |
| 75 | 77 | |
| 76 | - $variantIds = []; | |
| 78 | + // Guard: no terms selected → nothing to generate. Calling deleteOrphanVariant | |
| 79 | + // with an empty keep-list would match every row and wipe all variants for | |
| 80 | + // the product. Return empty rather than destroying existing data. | |
| 81 | + if (empty($variants)) { | |
| 82 | + return new Collection(); | |
| 83 | + } | |
| 77 | 84 | |
| 78 | 85 | $srcDetails->load('product'); |
| 79 | 86 | |
| 80 | - $variationTitle = $srcDetails->product->post_title; | |
| 87 | + // Preload every AttributeTerm referenced in this sync — one SELECT instead | |
| 88 | + // of one per term per variant (avoids N+1 on the term lookup). | |
| 89 | + $allTermIds = array_unique(array_merge(...array_map('array_values', $variants))); | |
| 90 | + $termsList = AttributeTerm::query()->whereIn('id', $allTermIds)->get(); | |
| 91 | + $termsMap = []; | |
| 92 | + foreach ($termsList as $term) { | |
| 93 | + $termsMap[$term->id] = $term; | |
| 94 | + } | |
| 81 | 95 | |
| 82 | - foreach ($variants as $index => $variant) { | |
| 96 | + $variantIds = []; | |
| 97 | + $newRelations = []; | |
| 83 | 98 | |
| 84 | - asort($variant, SORT_NUMERIC); | |
| 99 | + $db = ProductVariation::query()->getConnection(); | |
| 100 | + try { | |
| 101 | + $db->beginTransaction(); | |
| 85 | 102 | |
| 86 | - $variationIdentifier = implode('_', $variant); | |
| 103 | + foreach ($variants as $index => $variant) { | |
| 104 | + asort($variant, SORT_NUMERIC); | |
| 87 | 105 | |
| 88 | - $variantData = [ | |
| 89 | - 'post_id' => $srcDetails->post_id, | |
| 90 | - 'serial_index' => $index + 1, | |
| 91 | - 'stock' => 100, | |
| 92 | - 'item_price' => 0, | |
| 93 | - 'fulfillment_type' => 'physical', | |
| 94 | - 'variation_title' => $variationTitle, | |
| 95 | - 'variation_identifier' => $variationIdentifier, | |
| 96 | - 'other_info' => [ | |
| 97 | - 'variant' => array_values($variant), | |
| 98 | - ], | |
| 99 | - ]; | |
| 106 | + $variationIdentifier = implode('_', $variant); | |
| 100 | 107 | |
| 101 | - $exist = ProductVariation::query()->where('post_id', $srcDetails->post_id) | |
| 102 | - ->where('variation_identifier', $variationIdentifier) | |
| 103 | - ->first(); | |
| 108 | + $variantData = [ | |
| 109 | + 'post_id' => $srcDetails->post_id, | |
| 110 | + 'serial_index' => $index + 1, | |
| 111 | + 'stock' => 100, | |
| 112 | + 'item_price' => 0, | |
| 113 | + 'fulfillment_type' => 'physical', | |
| 114 | + 'variation_title' => $srcDetails->product->post_title, | |
| 115 | + 'variation_identifier' => $variationIdentifier, | |
| 116 | + 'other_info' => [ | |
| 117 | + 'variant' => array_values($variant), | |
| 118 | + ], | |
| 119 | + ]; | |
| 104 | 120 | |
| 105 | - if ($exist) { | |
| 121 | + $exist = ProductVariation::query() | |
| 122 | + ->where('post_id', $srcDetails->post_id) | |
| 123 | + ->where('variation_identifier', $variationIdentifier) | |
| 124 | + ->first(); | |
| 106 | 125 | |
| 107 | - $exist->serial_index = $index + 1; | |
| 108 | - $exist->save(); | |
| 126 | + if ($exist) { | |
| 127 | + $exist->serial_index = $index + 1; | |
| 128 | + $exist->save(); | |
| 129 | + } else { | |
| 130 | + $exist = ProductVariation::create($variantData); | |
| 131 | + } | |
| 109 | 132 | |
| 110 | - } else { | |
| 133 | + $variantIds[] = $exist->id; | |
| 111 | 134 | |
| 112 | - $exist = ProductVariation::create($variantData); | |
| 135 | + foreach ($variant as $termId) { | |
| 136 | + if (!isset($termsMap[$termId])) { | |
| 137 | + continue; | |
| 138 | + } | |
| 139 | + $term = $termsMap[$termId]; | |
| 140 | + $newRelations[] = [ | |
| 141 | + 'term_id' => $term->id, | |
| 142 | + 'object_id' => $exist->id, | |
| 143 | + 'group_id' => $term->group_id, | |
| 144 | + ]; | |
| 145 | + } | |
| 113 | 146 | } |
| 114 | 147 | |
| 148 | + // Bulk-insert relations — fetch existing keys first (one query) then | |
| 149 | + // insert only the missing ones in 100-row chunks instead of one | |
| 150 | + // firstOrCreate per term per variant. | |
| 151 | + if (!empty($newRelations)) { | |
| 152 | + $existingKeys = []; | |
| 153 | + $existing = AttributeRelation::query()->whereIn('object_id', $variantIds)->get(); | |
| 154 | + foreach ($existing as $rel) { | |
| 155 | + $existingKeys[$rel->object_id . ':' . $rel->term_id] = true; | |
| 156 | + } | |
| 157 | + $toInsert = array_values(array_filter($newRelations, function ($r) use ($existingKeys) { | |
| 158 | + return !isset($existingKeys[$r['object_id'] . ':' . $r['term_id']]); | |
| 159 | + })); | |
| 160 | + foreach (array_chunk($toInsert, 100) as $chunk) { | |
| 161 | + AttributeRelation::query()->insert($chunk); | |
| 162 | + } | |
| 163 | + } | |
| 115 | 164 | |
| 116 | - foreach ($variant as $termId) { | |
| 117 | - $term = AttributeTerm::find($termId); | |
| 165 | + self::deleteOrphanVariant($srcDetails->post_id, $variantIds); | |
| 118 | 166 | |
| 119 | - $relation = [ | |
| 120 | - 'term_id' => $term->id, | |
| 121 | - 'object_id' => $exist->id, | |
| 122 | - 'group_id' => $term->group_id, | |
| 123 | - ]; | |
| 167 | + $db->commit(); | |
| 168 | + } catch (\Exception $e) { | |
| 169 | + $db->rollBack(); | |
| 170 | + throw $e; | |
| 171 | + } | |
| 124 | 172 | |
| 125 | - AttributeRelation::firstOrCreate($relation); | |
| 126 | - } | |
| 173 | + return ProductVariation::query()->whereIn('id', $variantIds)->get(); | |
| 174 | + } | |
| 127 | 175 | |
| 128 | - $variantIds[] = $exist->id; | |
| 176 | + | |
| 177 | + /** | |
| 178 | + * Build a bounded, human-readable summary of variation titles for activity | |
| 179 | + * logs. When the count is within $limit the full list is returned; only when | |
| 180 | + * there are MORE than $limit titles do we sample the first $limit and append | |
| 181 | + * "and N more", so large combination sets don't bloat the log content. | |
| 182 | + * | |
| 183 | + * @param array $titles | |
| 184 | + * @param int $limit | |
| 185 | + * @return string | |
| 186 | + */ | |
| 187 | + public static function summarizeVariationTitles(array $titles, $limit = 5) | |
| 188 | + { | |
| 189 | + $titles = array_values(array_filter($titles, function ($title) { | |
| 190 | + return $title !== null && $title !== ''; | |
| 191 | + })); | |
| 192 | + | |
| 193 | + $total = count($titles); | |
| 194 | + | |
| 195 | + if ($total <= $limit) { | |
| 196 | + return implode(', ', $titles); | |
| 129 | 197 | } |
| 130 | 198 | |
| 131 | - /* | |
| 132 | - * Remove orphan variants | |
| 133 | - */ | |
| 134 | - self::deleteOrphanVariant($srcDetails->post_id, $variantIds); | |
| 135 | - | |
| 136 | - return ProductVariation::query()->whereIn('id', $variantIds)->get(); | |
| 199 | + return sprintf( | |
| 200 | + /* translators: %1$s: sample of variation titles, %2$d: number of remaining variations not listed */ | |
| 201 | + __('%1$s and %2$d more', 'fluent-cart'), | |
| 202 | + implode(', ', array_slice($titles, 0, $limit)), | |
| 203 | + $total - $limit | |
| 204 | + ); | |
| 137 | 205 | } |
| 138 | 206 | |
| 139 | - | |
| 140 | 207 | /** |
| 141 | 208 | * |
| 142 | 209 | * @param $productId |
| 143 | 210 | * @param array $childrenIdsWeWantToKeepSafe |
| 211 | + * @param string $reason Human-readable cause for the deletion, shown in the | |
| 212 | + * log content (e.g. "the variation type was changed to | |
| 213 | + * 'Simple'"). Defaults to a generic, always-true phrase | |
| 214 | + * so the message never claims the wrong cause — this is | |
| 215 | + * called from several flows (Simple switch, option/group | |
| 216 | + * changes, regeneration), not only the Simple switch. | |
| 144 | 217 | * @return mixed |
| 145 | 218 | */ |
| 146 | - public static function deleteOrphanVariant($productId, array $childrenIdsWeWantToKeepSafe = []) | |
| 219 | + public static function deleteOrphanVariant($productId, array $childrenIdsWeWantToKeepSafe = [], $reason = '') | |
| 147 | 220 | { |
| 148 | - $variations = ProductVariation::query() | |
| 149 | - ->select('variation_title') | |
| 221 | + $orphans = ProductVariation::query() | |
| 222 | + ->select(['id', 'variation_title']) | |
| 150 | 223 | ->where('post_id', $productId) |
| 151 | 224 | ->whereNotIn('id', $childrenIdsWeWantToKeepSafe) |
| 152 | 225 | ->get(); |
| 153 | 226 | |
| 154 | - $variationTitles = $variations->pluck('variation_title') | |
| 155 | - ->join(',', __(' and ', 'fluent-cart')); | |
| 227 | + if ($orphans->isEmpty()) { | |
| 228 | + return 0; | |
| 229 | + } | |
| 156 | 230 | |
| 157 | - if ($variations->count()) { | |
| 158 | - fluent_cart_success_log( | |
| 159 | - sprintf( | |
| 160 | - /* translators: %s is the number of variations */ | |
| 161 | - __('%s Pricing deleted', 'fluent-cart'), | |
| 162 | - $variations->count() | |
| 231 | + $orphanIds = $orphans->pluck('id')->toArray(); | |
| 232 | + | |
| 233 | + // Summarize rather than join every title — a product can have hundreds | |
| 234 | + // of combinations and the full list bloats the activity-log content. | |
| 235 | + // Below the cap the full list is kept; above it we sample + "and N more". | |
| 236 | + $variationTitles = static::summarizeVariationTitles( | |
| 237 | + $orphans->pluck('variation_title')->all() | |
| 238 | + ); | |
| 239 | + | |
| 240 | + if ($reason === '') { | |
| 241 | + $reason = __('the product variations were updated', 'fluent-cart'); | |
| 242 | + } | |
| 243 | + | |
| 244 | + fluent_cart_success_log( | |
| 245 | + sprintf( | |
| 246 | + /* translators: %1$s: number of variations deleted */ | |
| 247 | + __('%1$s Pricing deleted', 'fluent-cart'), | |
| 248 | + $orphans->count() | |
| 249 | + ), | |
| 250 | + sprintf( | |
| 251 | + /* translators: %1$s: variation titles, %2$s: reason the pricings were deleted */ | |
| 252 | + _n( | |
| 253 | + '%1$s Pricing is deleted, while %2$s', | |
| 254 | + "%1\$s Pricing's are deleted, while %2\$s", | |
| 255 | + $orphans->count(), | |
| 256 | + 'fluent-cart' | |
| 163 | 257 | ), |
| 164 | - sprintf( | |
| 165 | - /* translators: %s is the variation titles */ | |
| 166 | - _n( | |
| 167 | - "%s Pricing is deleted, while product variation is changed to 'Simple'", | |
| 168 | - "%s Pricing's are deleted, while product variation is changed to 'Simple'", | |
| 169 | - $variations->count(), | |
| 170 | - 'fluent-cart' | |
| 171 | - ), | |
| 172 | - $variationTitles | |
| 173 | - ), | |
| 174 | - [ | |
| 175 | - 'module_name' => 'Product', | |
| 176 | - 'module_id' => 0, | |
| 177 | - 'module_type' => ProductVariation::class, | |
| 178 | - ] | |
| 179 | - ); | |
| 258 | + $variationTitles, | |
| 259 | + $reason | |
| 260 | + ), | |
| 261 | + [ | |
| 262 | + 'module_name' => 'Product', | |
| 263 | + 'module_id' => 0, | |
| 264 | + 'module_type' => ProductVariation::class, | |
| 265 | + ] | |
| 266 | + ); | |
| 267 | + | |
| 268 | + // Bulk query-builder deletes bypass the ProductVariation::boot() deleting | |
| 269 | + // event, so neither $model->attrMap()->delete() nor deleteVariationMedia() | |
| 270 | + // fires. Explicitly purge both tables for every orphaned variation before | |
| 271 | + // deleting the variations themselves to prevent orphaned rows. | |
| 272 | + AttributeRelation::query()->whereIn('object_id', $orphanIds)->delete(); | |
| 273 | + ProductMeta::query()->where('object_type', 'product_variant_info')->whereIn('object_id', $orphanIds)->delete(); | |
| 274 | + | |
| 275 | + $orphanIds = ProductVariation::query() | |
| 276 | + ->select('id') | |
| 277 | + ->where('post_id', $productId) | |
| 278 | + ->whereNotIn('id', $childrenIdsWeWantToKeepSafe) | |
| 279 | + ->pluck('id') | |
| 280 | + ->toArray(); | |
| 281 | + | |
| 282 | + // Attribute relations table only exists when pro is active — guard before querying. | |
| 283 | + if ($orphanIds && \FluentCart\App\App::isProActive()) { | |
| 284 | + AttributeRelation::query()->whereIn('object_id', $orphanIds)->delete(); | |
| 180 | 285 | } |
| 181 | 286 | |
| 182 | - | |
| 183 | 287 | return ProductVariation::query() |
| 184 | - ->select('id') | |
| 185 | 288 | ->where('post_id', $productId) |
| 186 | 289 | ->whereNotIn('id', $childrenIdsWeWantToKeepSafe) |
| 187 | 290 | ->delete(); |
| 188 | 291 | } |