PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | app/Helpers/ProductAdminHelper.php +174 -71 1.3.25 → 1.6.5 View file →
@@ -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 }