Setting the file. One moment.
Selection Route · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
function statusOnly
— line 137
This file
Number 41.158
Position 158 of 174
Type JavaScript
Size 17 KB
Lines 343 lib/ selection-route.js
JavaScript · 343 lines · 17 KB
14 // modifier; it is never enumerated, so Wix's option/variant limits do not apply to it.
15 // - Every other attribute becomes a variant option. Open records are expanded over the declared
16 // choices with first-match precedence, within Wix limits.
17 // - A tracked quantity is a pool. It is never copied onto several variants: an open record that
18 // tracks quantity and covers several variants imports as stock status only, with a
19 // high-severity operator action (owner-approved policy).
20 // - Over the limits with per-choice price/stock: attributes that move price/stock stay variants
21 // when they fit, the rest become modifiers; otherwise one default variant at the lowest source
22 // price, every attribute a modifier, and an operator action listing the prices (owner-approved policy).
23 // - Never throws for a shape it can represent; throws only for contradictory input (a scope the
24 // records disprove, a choice the attribute does not declare), which the caller reports as a gap.
25
26 const LIMITS = Object. freeze ({ options: 6 , variants: 1000 , choicesPerOption: 100 });
27 const SCOPES = new Set ([ 'shared' , 'partial' , 'per-choice' ]);
28
29 const own = ( object , key ) => Object . prototype .hasOwnProperty. call (object || {}, key);
30 const fixedChoice = ( record , name ) => (record.choices || []). find (( c ) => c.optionName === name);
31 const tracksQuantity = ( record ) => record.inventoryTracked === true
32 || ( own (record, 'inventoryQuantity' ) && record.inventoryQuantity != null && record.inventoryQuantity !== '' && record.inventoryTracked !== false );
33 const actualPrice = ( record ) => {
34 const list = Number (record.price);
35 const sale = Number (record.discountedPrice);
36 return Number. isFinite (sale) && sale > 0 && sale < list ? sale : list;
37 };
38 const signature = ( record ) => JSON . stringify ([record.price ?? null , record.discountedPrice ?? null ,
39 tracksQuantity (record) ? [ 'q' , Number (record.inventoryQuantity)] : [ 's' , record.inStock ?? null ]]);
40
41 function fail ( product , message ) {
42 const error = new Error ( `selection route: "${ product . name }" (${ product . sourceId }) ${ message }` );
43 error.code = 'SELECTION_ROUTE_CONTRADICTION' ;
44 throw error;
45 }
46
47 // Checks the input and returns a copy keyed by unique labels. Attributes are identified by position:
48 // two attributes may share a label, and the later ones get a deterministic suffix ("Flavor (2)")
49 // used as their option or modifier name. A record choice names its attribute by `attributeIndex`
50 // when set, otherwise by a label that must be unique.
51 function normalize ( product ) {
52 const attributes = product.selectionAttributes;
53 if ( ! Array. isArray (attributes)) fail (product, 'selectionAttributes must be an array' );
54 for ( const a of attributes) {
55 if ( ! a || typeof a.name !== 'string' || ! a.name. trim ()) fail (product, 'has a selection attribute without a name' );
56 if ( ! Array. isArray (a.choices) || ! a.choices. length || a.choices. some (( c ) => typeof c !== 'string' || ! c. trim ())) fail (product, `attribute "${ a . name }" needs a non-empty list of choice names` );
57 if ( new Set (a.choices).size !== a.choices. length ) fail (product, `attribute "${ a . name }" repeats a choice` );
58 if (a.priceStockScope != null && ! SCOPES . has (a.priceStockScope)) fail (product, `attribute "${ a . name }" has unknown priceStockScope "${ a . priceStockScope }"` );
59 }
60 const declared = product.sourceDeclaredOptionNames;
61 if (Array. isArray (declared) && (declared. length !== attributes. length || declared. some (( n , i ) => n !== attributes[i].name))) {
62 fail (product, 'sourceDeclaredOptionNames must name the selection attributes in the same order' );
63 }
64 const taken = new Set (attributes. map (( a ) => a.name));
65 const seen = new Set ();
66 const labels = attributes. map (( a ) => {
67 if ( ! seen. has (a.name)) { seen. add (a.name); return a.name; }
68 let label;
69 for ( let n = 2 ; taken. has (label = `${ a . name } (${ n })` ); n ++ );
70 taken. add (label);
71 return label;
72 });
73 const variants = (product.variants || []). map (( record ) => {
74 const fixed = new Set ();
75 const choices = (record.choices || []). map (( c ) => {
76 let at;
77 if (c.attributeIndex != null ) {
78 at = c.attributeIndex;
79 if ( ! Number. isInteger (at) || ! attributes[at] || attributes[at].name !== c.optionName) fail (product, `has a record choice whose attributeIndex ${ at } is not attribute "${ c . optionName }"` );
80 } else {
81 const matches = attributes. map (( x , i ) => (x.name === c.optionName ? i : - 1 )). filter (( i ) => i >= 0 );
82 if ( ! matches. length ) fail (product, `has a record fixing undeclared attribute "${ c . optionName }"` );
83 if (matches. length > 1 ) fail (product, `has a record fixing "${ c . optionName }", which is declared twice, without attributeIndex` );
84 [at] = matches;
85 }
86 if (fixed. has (at)) fail (product, `has a record fixing attribute "${ c . optionName }" twice` );
87 fixed. add (at);
88 if ( ! attributes[at].choices. includes (c.choiceName)) fail (product, `has a record with choice "${ c . choiceName }" that attribute "${ c . optionName }" does not declare` );
89 return { optionName: labels[at], choiceName: c.choiceName };
90 });
91 return { ... record, choices };
92 });
93 return {
94 product: { ... product, selectionAttributes: attributes. map (( a , i ) => ({ ... a, name: labels[i] })), variants },
95 renamed: attributes. map (( a , i ) => ({ attributeIndex: i, sourceName: a.name, name: labels[i] })). filter (( r ) => r.sourceName !== r.name),
96 };
97 }
98
99 // The effective scope from the records. An explicit scope the records contradict is refused.
100 // Only an explicit `shared` is modifier-eligible: an unset scope with open records is expanded
101 // into variants, never guessed into a modifier.
102 function effectiveScope ( product , attribute ) {
103 const records = product.variants || [];
104 const fixed = records. filter (( r ) => fixedChoice (r, attribute.name)). length ;
105 if (attribute.priceStockScope === 'shared' ) {
106 if (fixed) fail (product, `attribute "${ attribute . name }" is declared shared but ${ fixed } record(s) fix it` );
107 return 'shared' ;
108 }
109 if (attribute.priceStockScope === 'per-choice' && fixed !== records. length ) fail (product, `attribute "${ attribute . name }" is declared per-choice but some records leave it open` );
110 return fixed === records. length ? 'per-choice' : 'partial' ;
111 }
112
113 // Product of choice counts, stopping as soon as the limit is passed (never builds the set).
114 function boundedCount ( attributes , limit ) {
115 let count = 1 ;
116 for ( const a of attributes) { count *= a.choices. length ; if (count > limit) return limit + 1 ; }
117 return count;
118 }
119
120 function fits ( attributes ) {
121 return attributes. length <= LIMITS .options
122 && attributes. every (( a ) => a.choices. length <= LIMITS .choicesPerOption)
123 && boundedCount (attributes, LIMITS .variants) <= LIMITS .variants;
124 }
125
126 function* combinations ( attributes ) {
127 const index = attributes. map (() => 0 );
128 if ( ! attributes. length ) return ;
129 while ( true ) {
130 yield attributes. map (( a , i ) => ({ optionName: a.name, choiceName: a.choices[index[i]] }));
131 let i = attributes. length - 1 ;
132 while (i >= 0 && ++ index[i] === attributes[i].choices. length ) { index[i] = 0 ; i -- ; }
133 if (i < 0 ) return ;
134 }
135 }
136
137 function statusOnly ( record ) {
138 const out = { ... record, inventoryTracked: false , inStock: Number (record.inventoryQuantity) > 0 };
139 delete out.inventoryQuantity;
140 return out;
141 }
142
143 // Expand records over the variant attributes, first match wins. A combination no record matches
144 // is not sellable in the source and gets no variant.
145 function expand ( product , variantAttributes , operatorActions ) {
146 const records = product.variants || [];
147 const matched = records. map (() => []);
148 for ( const combo of combinations (variantAttributes)) {
149 const at = records. findIndex (( r ) => combo. every (( c ) => {
150 const f = fixedChoice (r, c.optionName);
151 return ! f || f.choiceName === c.choiceName;
152 }));
153 if (at >= 0 ) matched[at]. push (combo);
154 }
155 const variants = [];
156 records. forEach (( record , at ) => {
157 const pooled = tracksQuantity (record) && matched[at]. length > 1 ;
158 if (pooled) {
159 operatorActions. push ({
160 code: 'shared-stock-pool-not-tracked' ,
161 severity: 'high' ,
162 sourceId: product.sourceId,
163 sourceRecord: record.sourceId ?? at,
164 quantity: Number (record.inventoryQuantity),
165 variantCount: matched[at]. length ,
166 detail: 'one source stock pool covers several Wix variants; imported as stock status only. Set stock tracking by hand before launch.' ,
167 });
168 }
169 // One source record can cover several Wix variants: `variantKey` is unique per Wix variant
170 // (the record id, plus the choices when the record was expanded), for variant expectations
171 // and the crosswalk. `sourceId` stays the source record's id.
172 const recordKey = String (record.sourceId ?? at);
173 for ( const combo of matched[at]) {
174 const variantKey = matched[at]. length > 1 ? `${ recordKey }#${ combo . map (( c ) => c . choiceName ). join ( '|' ) }` : recordKey;
175 variants. push ({ ... (pooled ? statusOnly (record) : record), choices: combo, variantKey });
176 }
177 });
178 return variants;
179 }
180
181 // An attribute moves price/stock when two records that fix it to different values differ in
182 // price, sale price or stock. Over-counts (safe: more variants, fewer modifiers).
183 function movesPriceOrStock ( product , attribute ) {
184 const records = (product.variants || []). filter (( r ) => fixedChoice (r, attribute.name));
185 for ( let i = 0 ; i < records. length ; i ++ ) {
186 for ( let j = i + 1 ; j < records. length ; j ++ ) {
187 if ( fixedChoice (records[i], attribute.name).choiceName !== fixedChoice (records[j], attribute.name).choiceName
188 && signature (records[i]) !== signature (records[j])) return true ;
189 }
190 }
191 return false ;
192 }
193
194 function modifierFor ( attribute ) {
195 return { name: attribute.name, customizationName: attribute.name, choices: [ ... attribute.choices], mandatory: attribute.required !== false };
196 }
197
198 function dropChoices ( record , names ) {
199 return { ... record, choices: (record.choices || []). filter (( c ) => ! names. has (c.optionName)) };
200 }
201
202 // Returns { product, route, decision, renamed, operatorActions }. `product` is a copy carrying optionNames, variants,
203 // modifiers and sourceDeclaredOptionNames, ready for buildProduct. Products without
204 // selectionAttributes are returned unchanged with route 'none'.
205 function routeSelections ( product ) {
206 if ( ! product || ! Array. isArray (product.selectionAttributes) || ! product.selectionAttributes. length ) {
207 return { product, route: 'none' , operatorActions: [] };
208 }
209 const normalized = normalize (product);
210 product = normalized.product;
211 const attributes = product.selectionAttributes;
212 const records = product.variants || [];
213 if ( ! records. length ) fail (product, 'declares selection attributes but has no sellable record' );
214 const operatorActions = [];
215 const shared = attributes. filter (( a ) => effectiveScope (product, a) === 'shared' );
216 let variantAttributes = attributes. filter (( a ) => ! shared. includes (a));
217 let modifierAttributes = [ ... shared];
218 let variants;
219 let decision = null ;
220
221 if ( ! variantAttributes. length ) {
222 // Every attribute is shared: one default variant. Only the first record can ever match.
223 variants = [{ ... records[ 0 ], choices: [], variantKey: String (records[ 0 ].sourceId ?? 0 ) }];
224 } else if ( fits (variantAttributes)) {
225 variants = expand (product, variantAttributes, operatorActions);
226 } else {
227 decision = 'B' ;
228 const moving = variantAttributes. filter (( a ) => movesPriceOrStock (product, a));
229 if (moving. length && fits (moving)) {
230 const demoted = new Set (variantAttributes. filter (( a ) => ! moving. includes (a)). map (( a ) => a.name));
231 modifierAttributes = attributes. filter (( a ) => shared. includes (a) || demoted. has (a.name));
232 variantAttributes = moving;
233 variants = expand ({ ... product, variants: records. map (( r ) => dropChoices (r, demoted)) }, variantAttributes, operatorActions);
234 operatorActions. push ({
235 code: 'selection-over-variant-limits' ,
236 severity: 'medium' ,
237 sourceId: product.sourceId,
238 modifierAttributes: [ ... demoted],
239 detail: 'over Wix variant limits; attributes that do not change price or stock became modifiers. Check the product page before launch.' ,
240 });
241 } else {
242 const cheapest = records. reduce (( best , r ) => ( actualPrice (r) < actualPrice (best) ? r : best), records[ 0 ]);
243 const anyTracked = records. some (tracksQuantity);
244 const base = anyTracked ? { ... statusOnly (cheapest), inStock: records. some (( r ) => ( tracksQuantity (r) ? Number (r.inventoryQuantity) > 0 : r.inStock !== false )) } : cheapest;
245 variants = [{ ... base, choices: [], variantKey: String (cheapest.sourceId ?? records. indexOf (cheapest)) }];
246 modifierAttributes = [ ... attributes];
247 variantAttributes = [];
248 operatorActions. push ({
249 code: 'selection-prices-not-represented' ,
250 severity: 'high' ,
251 sourceId: product.sourceId,
252 importedPrice: cheapest.price,
253 sourcePrices: records. map (( r , at ) => ({ sourceRecord: r.sourceId ?? at, choices: r.choices || [], price: r.price, discountedPrice: r.discountedPrice ?? null })),
254 stockTrackingDropped: anyTracked,
255 detail: 'per-choice prices exceed Wix variant limits; imported at the lowest source price with every attribute as a modifier. Set prices by hand before launch.' ,
256 });
257 }
258 }
259
260 // Labels are unique per product (normalize), so each modifier has its own customization name.
261 const modifiers = attributes. filter (( a ) => modifierAttributes. includes (a)). map (modifierFor);
262 const route = ! modifiers. length ? 'variants' : ! variantAttributes. length ? 'modifiers' : 'hybrid' ;
263 return {
264 product: {
265 ... product,
266 sourceDeclaredOptionNames: attributes. map (( a ) => a.name),
267 optionNames: variantAttributes. map (( a ) => a.name),
268 variants,
269 modifiers,
270 },
271 route,
272 decision,
273 renamed: normalized.renamed,
274 operatorActions,
275 };
276 }
277
278 // The per-product route record the importer persists with the product's verification row, so the
279 // cutover storefront check can list modifier-route products from an artifact, not from chat.
280 function routeRecord ( routed ) {
281 return {
282 route: routed.route,
283 decision: routed.decision ?? null ,
284 modifiers: (routed.product.modifiers || []). map (( m ) => m.customizationName),
285 renamed: routed.renamed || [],
286 operatorActions: (routed.operatorActions || []). map (( a ) => a.code),
287 };
288 }
289
290 function choicesKey ( choices ) { return JSON . stringify (choices); }
291
292 // Catalog-wide customization names. Wix keeps one customization per name (and refuses a
293 // same-named group with different settings), so a label reused with a different choice list gets
294 // a deterministic suffix in catalog order. Same label and same choices share one customization.
295 // Mutates each routed product's modifiers[].customizationName; returns the customizations to write.
296 function planSelectionCustomizations ( routedProducts ) {
297 const byKey = new Map ();
298 const taken = new Set ();
299 const customizations = [];
300 for ( const product of routedProducts) {
301 const seenInProduct = new Map ();
302 for ( const m of product.modifiers || []) {
303 const base = m.customizationName || m.name;
304 const occurrence = (seenInProduct. get (base + choicesKey (m.choices)) || 0 ) + 1 ;
305 seenInProduct. set (base + choicesKey (m.choices), occurrence);
306 const key = JSON . stringify ([base, m.choices, occurrence]);
307 if ( ! byKey. has (key)) {
308 let name = base;
309 for ( let n = 2 ; taken. has (name); n ++ ) name = `${ base } (${ n })` ;
310 taken. add (name);
311 byKey. set (key, name);
312 customizations. push ( customizationPayload ({ ... m, customizationName: name }));
313 }
314 m.customizationName = byKey. get (key);
315 }
316 }
317 return customizations;
318 }
319
320 // The standalone customization create body for one modifier (stores/product-customization).
321 function customizationPayload ( modifier ) {
322 return {
323 customization: {
324 name: modifier.customizationName,
325 customizationType: 'MODIFIER' ,
326 customizationRenderType: 'TEXT_CHOICES' ,
327 choicesSettings: { choices: modifier.choices. map (( name ) => ({ choiceType: 'CHOICE_TEXT' , name })) },
328 },
329 };
330 }
331
332 // The read-back expectation for a built product's modifiers, derived from what the source
333 // declared (the routed canonical product), not from the submitted body.
334 function modifierExpectation ( routedProduct , connectedModifiers ) {
335 return (routedProduct.modifiers || []). map (( m , i ) => ({
336 id: connectedModifiers[i].id,
337 name: m.customizationName,
338 mandatory: m.mandatory,
339 choicesSettings: { choices: m.choices. map (( name ) => ({ name })) },
340 }));
341 }
342
343 module . exports = { LIMITS, routeSelections, routeRecord, planSelectionCustomizations, customizationPayload, modifierExpectation };