From d141e5b034a647031a1d91577daf1dfd1eee65d2 Mon Sep 17 00:00:00 2001 From: Mohammed-Fardeen-02 Date: Tue, 1 Sep 2026 15:35:56 +0530 Subject: [PATCH] fixed bulk status update for variant --- .../adapters/shopify/shopify.mapper.js | 13 +- .../adapters/shopify/shopify.publisher.js | 191 ++++++++++++++++-- .../projection/canonical-product.builder.js | 27 ++- 3 files changed, 213 insertions(+), 18 deletions(-) diff --git a/src/features/integrations/adapters/shopify/shopify.mapper.js b/src/features/integrations/adapters/shopify/shopify.mapper.js index ba63d91..4aaaf46 100644 --- a/src/features/integrations/adapters/shopify/shopify.mapper.js +++ b/src/features/integrations/adapters/shopify/shopify.mapper.js @@ -16,7 +16,7 @@ export const shopifyMapper = { .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, ''); - const uniqueSuffix = String(identity.id).replace(/[^a-z0-9]/gi, '').slice(0, 10); + const uniqueSuffix = String(identity.id).replace(/[^a-z0-9]/gi, '').slice(0, 8) + '-' + Date.now().toString(36); input.handle = `${baseSlug}-${uniqueSuffix}`; } @@ -30,7 +30,16 @@ export const shopifyMapper = { }); if (optionKeys.size > 0) { - input.productOptions = Array.from(optionKeys).slice(0, 3).map(optName => ({ + const priorityOrder = ['color', 'size', 'storage', 'ram', 'rom', 'material', 'style']; + const top3OptionKeys = Array.from(optionKeys).sort((a, b) => { + const idxA = priorityOrder.indexOf(a.toLowerCase()); + const idxB = priorityOrder.indexOf(b.toLowerCase()); + const pA = idxA === -1 ? 99 : idxA; + const pB = idxB === -1 ? 99 : idxB; + return pA - pB; + }).slice(0, 3); + + input.productOptions = top3OptionKeys.map(optName => ({ name: optName, values: Array.from(new Set( variants diff --git a/src/features/integrations/adapters/shopify/shopify.publisher.js b/src/features/integrations/adapters/shopify/shopify.publisher.js index c12a8b7..9282351 100644 --- a/src/features/integrations/adapters/shopify/shopify.publisher.js +++ b/src/features/integrations/adapters/shopify/shopify.publisher.js @@ -9,6 +9,16 @@ const PRODUCT_CREATE_MUTATION = ` title handle onlineStoreUrl + variants(first: 50) { + nodes { + id + title + selectedOptions { + name + value + } + } + } } userErrors { field @@ -26,6 +36,16 @@ const PRODUCT_UPDATE_MUTATION = ` title handle onlineStoreUrl + variants(first: 50) { + nodes { + id + title + selectedOptions { + name + value + } + } + } } userErrors { field @@ -62,6 +82,38 @@ const PRODUCT_CREATE_MEDIA_MUTATION = ` } `; +const BULK_VARIANTS_CREATE_MUTATION = ` + mutation productVariantsBulkCreate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { + productVariantsBulkCreate(productId: $productId, variants: $variants) { + productVariants { + id + title + price + } + userErrors { + field + message + } + } + } +`; + +const BULK_VARIANTS_UPDATE_MUTATION = ` + mutation productVariantsBulkUpdate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { + productVariantsBulkUpdate(productId: $productId, variants: $variants) { + productVariants { + id + title + price + } + userErrors { + field + message + } + } + } +`; + export class ShopifyPublisher { constructor(shopifyClient) { this.client = shopifyClient; @@ -107,14 +159,119 @@ export class ShopifyPublisher { throw new Error('Shopify GraphQL returned empty product response'); } - // Attach media/image URLs to Shopify product - const validMedia = (canonicalProduct.media || []) - .filter(m => m.url && (m.url.startsWith('http://') || m.url.startsWith('https://'))) - .map(m => ({ - mediaContentType: 'IMAGE', - originalSource: m.url, - alt: canonicalProduct.content.name - })); + // ── Multi-Variant Bulk Creation & Synchronization ── + const pimVariants = canonicalProduct.variants || []; + if (Array.isArray(pimVariants) && pimVariants.length > 0) { + const existingNodes = shopifyProduct.variants?.nodes || []; + + // Allowed option names in Shopify product + const allowedOptionNames = (input.productOptions || []).map(o => o.name); + + const newVariantsInput = []; + const updateVariantsInput = []; + + for (const v of pimVariants) { + let optionValues = Object.entries(v.attributes || {}).map(([optName, optVal]) => ({ + optionName: optName, + name: String(optVal) + })); + + if (allowedOptionNames.length > 0) { + optionValues = optionValues.filter(ov => + allowedOptionNames.some(aName => aName.toLowerCase() === ov.optionName.toLowerCase()) + ); + } + + if (optionValues.length === 0) continue; + + // Try matching with existing Shopify variant node + const matchedNode = existingNodes.find(node => { + const nodeOptions = node.selectedOptions || []; + return optionValues.every(ov => + nodeOptions.some(no => no.name.toLowerCase() === ov.optionName.toLowerCase() && no.value.toLowerCase() === ov.name.toLowerCase()) + ); + }); + + if (matchedNode) { + updateVariantsInput.push({ + id: matchedNode.id, + price: String(v.price || 0), + compareAtPrice: v.costPrice ? String(v.costPrice) : null, + inventoryItem: { + sku: v.sku || '', + tracked: true + } + }); + } else { + newVariantsInput.push({ + optionValues, + price: String(v.price || 0), + compareAtPrice: v.costPrice ? String(v.costPrice) : null, + inventoryItem: { + sku: v.sku || '', + tracked: true + } + }); + } + } + + // Create new variants + if (newVariantsInput.length > 0) { + try { + const createRes = await this.client.graphql(BULK_VARIANTS_CREATE_MUTATION, { + productId: shopifyProduct.id, + variants: newVariantsInput + }); + const createErrors = createRes.data?.productVariantsBulkCreate?.userErrors; + if (createErrors?.length > 0) { + console.warn('[ShopifyPublisher] Variant create warnings:', createErrors.map(e => e.message).join(', ')); + } + } catch (vErr) { + console.warn('[ShopifyPublisher] Variant bulk create error:', vErr.message); + } + } + + // Update existing variants + if (updateVariantsInput.length > 0) { + try { + const updateRes = await this.client.graphql(BULK_VARIANTS_UPDATE_MUTATION, { + productId: shopifyProduct.id, + variants: updateVariantsInput + }); + const updateErrors = updateRes.data?.productVariantsBulkUpdate?.userErrors; + if (updateErrors?.length > 0) { + console.warn('[ShopifyPublisher] Variant update warnings:', updateErrors.map(e => e.message).join(', ')); + } + } catch (vErr) { + console.warn('[ShopifyPublisher] Variant bulk update error:', vErr.message); + } + } + } + + // ── Attach Media/Images to Shopify Product ── + const allMediaUrls = new Set(); + + // Product level media + (canonicalProduct.media || []).forEach(m => { + if (m.url && (m.url.startsWith('http://') || m.url.startsWith('https://'))) { + allMediaUrls.add(m.url); + } + }); + + // Variant level media + (pimVariants).forEach(v => { + (v.media || []).forEach(m => { + if (m.url && (m.url.startsWith('http://') || m.url.startsWith('https://'))) { + allMediaUrls.add(m.url); + } + }); + }); + + const validMedia = Array.from(allMediaUrls).map(url => ({ + mediaContentType: 'IMAGE', + originalSource: url, + alt: canonicalProduct.content.name + })); if (validMedia.length > 0 && shopifyProduct?.id) { try { @@ -158,7 +315,8 @@ export class ShopifyPublisher { } async deleteProduct(context) { - const { tenantId, integrationId, productId } = context; + const { tenantId, integrationId, canonicalProduct } = context; + const productId = canonicalProduct.identity.id; const existingResource = await models.ExternalResource.findOne({ where: { @@ -170,19 +328,24 @@ export class ShopifyPublisher { }); if (!existingResource) { - return { success: true, message: 'Resource was not published to Shopify' }; + return { success: true, message: 'No external mapping found for deletion' }; } const response = await this.client.graphql(PRODUCT_DELETE_MUTATION, { input: { id: existingResource.external_id } }); - const result = response.data?.productDelete; - if (result?.userErrors?.length > 0) { - throw new Error(`Shopify productDelete user errors: ${result.userErrors.map(e => e.message).join(', ')}`); + const userErrors = response.data?.productDelete?.userErrors; + if (userErrors?.length > 0) { + throw new Error(`Shopify productDelete user errors: ${userErrors.map(e => e.message).join(', ')}`); } await existingResource.destroy(); - return { success: true, deletedProductId: result.deletedProductId }; + + return { + success: true, + externalId: existingResource.external_id, + operation: 'DELETE' + }; } } diff --git a/src/features/integrations/projection/canonical-product.builder.js b/src/features/integrations/projection/canonical-product.builder.js index 68e919f..31d7e4c 100644 --- a/src/features/integrations/projection/canonical-product.builder.js +++ b/src/features/integrations/projection/canonical-product.builder.js @@ -80,8 +80,31 @@ export const canonicalProductBuilder = { const vAttrs = {}; if (Array.isArray(v.values)) { v.values.forEach(val => { - const key = val.axis?.code || val.attribute_id; - vAttrs[key] = val.value_text || val.value_number; + const rawKey = val.axis?.name || val.axis?.code || ''; + let key = rawKey + .replace(/_variant$/i, '') + .replace(/_/g, ' ') + .trim() + .replace(/\b\w/g, l => l.toUpperCase()); + + // Normalize common option names + if (/^colors?$/i.test(key)) key = 'Color'; + if (/^sizes?$/i.test(key)) key = 'Size'; + if (/^ram$/i.test(key)) key = 'RAM'; + + // Skip brand / vendor from variant options + if (!key || /^brand$/i.test(key) || /^vendor$/i.test(key) || key.toLowerCase().includes('brand_id')) { + return; + } + + let valStr = String(val.value_text || val.value_number || '').trim(); + // Skip if value looks like a UUID + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(valStr)) { + return; + } + if (valStr.length > 0) { + vAttrs[key] = valStr.length <= 3 ? valStr.toUpperCase() : valStr.charAt(0).toUpperCase() + valStr.slice(1); + } }); }