Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ee30fdb8e | ||
|
|
d141e5b034 |
@@ -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
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user