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