Tom Butcher 61d29a8f7e Add syncHash and syncImageHash fields to listing and variant schemas; implement sync fingerprinting for marketplace integration
This commit introduces new fields `syncHash` and `syncImageHash` to the `listing` and `listingVariant` schemas, enhancing the tracking of synchronization states. Additionally, a new module for sync fingerprinting is added, which includes functions to generate hashes for listings and their variants. The marketplace integration logic is updated to utilize these hashes for determining if updates are necessary, improving efficiency in syncing operations. Tests are also added to ensure the correctness of the new functionality and its integration with existing systems.
2026-08-29 22:34:11 +01:00

454 lines
14 KiB
JavaScript

import { syncProductCategory } from './categories.js';
import { syncListingPolicies } from './listingPolicies.js';
import {
buildVarientEntry,
fetchOffers,
fromEbayCondition,
resolveListingDescription,
resolveOfferState,
syncOfferAndMaybePublish,
upsertInventoryItem,
withdrawOfferById,
} from './listingVarients.js';
import { makeRequest, logger } from './shared.js';
import { getEbayItemUrl, parseEbayItemId } from './itemUrl.js';
import { marketplaceSku } from '../ids.js';
import { buildGroupVariesBy } from './variationAspects.js';
import { toEbayHtmlDescription } from './description.js';
import { attachImageUrlsToListingAndVarients } from './images.js';
import {
listingImageSyncHash,
listingImagesUnchanged,
payloadsEqual,
} from '../syncFingerprint.js';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function buildInventoryItemGroupBody(listing, varients) {
const groupKey = listing._reference;
const variantSKUs = varients.map((v) => marketplaceSku(v)).filter(Boolean);
const variation = buildGroupVariesBy(varients);
const body = {
title: listing.title || groupKey,
variantSKUs,
description: toEbayHtmlDescription(resolveListingDescription(listing)),
variesBy: variation.variesBy,
};
if (variation.aspects) {
body.aspects = variation.aspects;
}
if (listing.imageUrls?.length) {
body.imageUrls = listing.imageUrls;
const firstSpecName = variation.variesBy?.specifications?.[0]?.name;
if (firstSpecName) {
body.variesBy = {
...body.variesBy,
aspectsImageVariesBy: [firstSpecName],
};
}
}
return body;
}
function comparableInventoryGroup(group) {
if (!group) return null;
return {
title: group.title || '',
variantSKUs: group.variantSKUs || [],
description: group.description || '',
variesBy: group.variesBy || null,
aspects: group.aspects || null,
imageUrls: group.imageUrls || null,
};
}
async function createOrReplaceGroup(marketplace, listing, varients) {
const groupKey = listing._reference;
const body = buildInventoryItemGroupBody(listing, varients);
const existing = await safeFetchInventoryItemGroup(marketplace, groupKey);
if (existing && payloadsEqual(comparableInventoryGroup(existing), comparableInventoryGroup(body))) {
logger.debug(`Inventory item group "${groupKey}" unchanged — skipping eBay PUT`);
return;
}
await makeRequest({
marketplace,
method: 'PUT',
path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`,
body,
});
}
async function deleteGroup(marketplace, groupKey) {
try {
await makeRequest({
marketplace,
method: 'DELETE',
path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`,
acceptableStatuses: [404],
});
} catch (err) {
logger.warn(`Failed to delete inventory item group "${groupKey}": ${err.message}`);
}
}
async function syncSingleVarientListing(marketplace, listing, varient) {
await upsertInventoryItem(marketplace, varient, listing);
// If this listing used to be grouped, remove the stale group before treating it as a standalone item.
if (listing._reference) {
const existingGroup = await safeFetchInventoryItemGroup(marketplace, listing._reference);
if (existingGroup) {
await deleteGroup(marketplace, listing._reference);
}
}
return syncOfferAndMaybePublish(marketplace, listing, varient);
}
async function syncGroupedListing(marketplace, listing, varients) {
logger.info(
`Syncing eBay inventory item group "${listing._reference}" with ${varients.length} varient(s)`
);
for (const varient of varients) {
await upsertInventoryItem(marketplace, varient, listing);
}
// Brief delay so eBay can resolve the new inventory item SKUs before creating the group.
await sleep(1000);
await createOrReplaceGroup(marketplace, listing, varients);
let firstPublished = { url: '' };
for (const varient of varients) {
try {
const published = await syncOfferAndMaybePublish(marketplace, listing, varient);
if (published?.url && !firstPublished.url) firstPublished = published;
} catch (err) {
logger.warn(`Failed to create offer for varient ${varient._reference}: ${err.message}`);
}
}
return firstPublished;
}
async function syncListing(marketplace, listing, varients, actionLabel) {
const ref = listing._reference;
if (!ref) {
throw new Error(`Listing must have a _reference to ${actionLabel} on eBay`);
}
const validVarients = (varients || []).filter((varient) => varient?._reference);
if (validVarients.length === 0) {
throw new Error(
`Listing must have at least one varient with a _reference to ${actionLabel} on eBay`
);
}
const { listing: listingWithImages, varients: varientsWithImages } =
await attachImageUrlsToListingAndVarients(marketplace, listing, validVarients);
const category = await syncProductCategory(marketplace, listingWithImages, varientsWithImages);
const listingPolicies = await syncListingPolicies(marketplace, listingWithImages);
const listingWithContext = {
...listingWithImages,
...(category ? { categoryId: category.categoryId } : {}),
...listingPolicies,
};
const imageState = imageSyncState(
listing,
validVarients,
listingWithImages,
varientsWithImages
);
if (varientsWithImages.length === 1) {
logger.info(
`Syncing standalone eBay inventory item "${varientsWithImages[0]._reference}" for listing "${ref}"`
);
const published = await syncSingleVarientListing(
marketplace,
listingWithContext,
varientsWithImages[0]
);
return listingSyncResult(published, marketplace, imageState);
}
const published = await syncGroupedListing(marketplace, listingWithContext, varientsWithImages);
return listingSyncResult(published, marketplace, imageState);
}
function listingSyncResult(published, marketplace, imageState = {}) {
if (!published) {
return { url: '', ...imageState };
}
if (typeof published === 'string') {
return { url: published, ...imageState };
}
const listingId = parseEbayItemId(published.listingId || published.externalReference);
return {
url: published.url || getEbayItemUrl(marketplace, listingId),
...(listingId ? { externalReference: listingId } : {}),
...imageState,
};
}
function imageSyncState(listing, varients, listingWithImages, varientsWithImages) {
return {
syncImageHash: listingImageSyncHash(listing, varients),
marketplaceImageUrls: listingWithImages?.imageUrls || listingWithImages?.marketplaceImageUrls || [],
varients: (varientsWithImages || []).map((varient) => ({
_id: varient._id,
marketplaceImageUrls: varient.imageUrls || varient.marketplaceImageUrls || [],
syncImageHash: varient.syncImageHash,
})),
};
}
export async function createItem(marketplace, listing, varients) {
return syncListing(marketplace, listing, varients, 'create');
}
export async function updateItem(marketplace, listing, varients) {
return syncListing(marketplace, listing, varients, 'update');
}
export async function syncListingImages(marketplace, listing, varients = []) {
const validVarients = (varients || []).filter((varient) => varient?._reference);
if (validVarients.length === 0) return null;
if (listingImagesUnchanged(listing, validVarients) && listing.marketplaceImageUrls?.length) {
logger.debug(
`Listing "${listing._reference}" images unchanged — skipping eBay image sync`
);
return { listing, varients: validVarients, skipped: true };
}
const { listing: listingWithImages, varients: varientsWithImages } =
await attachImageUrlsToListingAndVarients(marketplace, listing, validVarients);
for (const varient of varientsWithImages) {
await upsertInventoryItem(marketplace, varient, listingWithImages);
}
return { listing: listingWithImages, varients: varientsWithImages };
}
export async function deleteItem(marketplace, listing, varients = []) {
const skus = varients.map((v) => marketplaceSku(v)).filter(Boolean);
for (const sku of skus) {
const offers = await fetchOffers(marketplace, sku);
for (const offer of offers) {
if (!offer?.offerId) continue;
try {
await withdrawOfferById(marketplace, offer.offerId);
} catch (err) {
logger.warn(`Failed to withdraw offer ${offer.offerId} for SKU ${sku}: ${err.message}`);
}
}
try {
await makeRequest({
marketplace,
method: 'DELETE',
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`,
acceptableStatuses: [404],
});
} catch (err) {
logger.warn(`Failed to delete eBay inventory item "${sku}": ${err.message}`);
}
}
const ref = listing._reference;
if (ref) {
logger.info(`Deleting eBay inventory item group "${ref}"`);
await deleteGroup(marketplace, ref);
}
}
// --- Sync helpers (inbound from eBay) ---
async function fetchAllInventoryItems(marketplace) {
const items = [];
let offset = 0;
const limit = 100;
do {
const data = await makeRequest({
marketplace,
path: '/sell/inventory/v1/inventory_item',
params: { limit, offset },
});
if (data?.inventoryItems?.length) {
items.push(...data.inventoryItems);
}
if (!data?.inventoryItems?.length || items.length >= (data.total || 0)) {
break;
}
offset += limit;
} while (true);
return items;
}
async function safeFetchInventoryItemGroup(marketplace, groupKey) {
try {
return await makeRequest({
marketplace,
path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`,
acceptableStatuses: [404],
});
} catch (err) {
logger.warn(`Failed to fetch inventory item group "${groupKey}": ${err.message}`);
return null;
}
}
export async function syncItems(marketplace) {
logger.info(`Syncing inventory from eBay marketplace: ${marketplace.name}`);
const inventoryItems = await fetchAllInventoryItems(marketplace);
const itemsBySku = new Map();
const groupKeysSet = new Set();
const groupedSkus = new Set();
for (const item of inventoryItems) {
itemsBySku.set(item.sku, item);
if (item.inventoryItemGroupKeys?.length) {
for (const key of item.inventoryItemGroupKeys) {
groupKeysSet.add(key);
}
}
}
const results = [];
for (const groupKey of groupKeysSet) {
const group = await safeFetchInventoryItemGroup(marketplace, groupKey);
if (!group) continue;
const variantSkus = group.variantSKUs || [];
for (const sku of variantSkus) {
groupedSkus.add(sku);
}
const variantItems = [];
for (const sku of variantSkus) {
const item = itemsBySku.get(sku);
if (item) {
try {
const offers = await fetchOffers(marketplace, sku);
variantItems.push({ ...item, _offers: offers });
} catch (err) {
logger.warn(`Failed to fetch offers for group variant SKU ${sku}: ${err.message}`);
variantItems.push({ ...item, _offers: [] });
}
}
}
results.push({
_type: 'group',
_groupKey: groupKey,
_group: group,
_variants: variantItems,
});
}
for (const item of inventoryItems) {
if (groupedSkus.has(item.sku)) continue;
try {
const offers = await fetchOffers(marketplace, item.sku);
results.push({
_type: 'single',
...item,
_offers: offers,
});
} catch (err) {
logger.warn(`Failed to fetch offers for SKU ${item.sku}: ${err.message}`);
results.push({
_type: 'single',
...item,
_offers: [],
});
}
}
logger.info(
`Fetched ${results.length} listing(s) from eBay (${groupKeysSet.size} group(s), ${results.length - groupKeysSet.size} standalone)`
);
return results;
}
export function mapProductToListing(ebayItem, marketplace) {
if (ebayItem._type === 'group') {
const group = ebayItem._group;
const variants = ebayItem._variants || [];
const allOffers = variants.flatMap((v) => v._offers || []);
const stateType = resolveOfferState(allOffers);
const firstPublishedOffer = allOffers.find((o) => o?.listingId || o?.listing?.listingId);
const publishedListingId = parseEbayItemId(
firstPublishedOffer?.listingId || firstPublishedOffer?.listing?.listingId
);
const listingId = publishedListingId || ebayItem._groupKey;
const url = getEbayItemUrl(marketplace, publishedListingId);
const firstOffer = allOffers[0];
const price = firstOffer?.pricingSummary?.price?.value
? parseFloat(firstOffer.pricingSummary.price.value)
: undefined;
const currency = firstOffer?.pricingSummary?.price?.currency || undefined;
const varients = variants.map((v) => buildVarientEntry(v, v._offers || []));
return {
externalReference: listingId,
title: group.title || ebayItem._groupKey,
description: group.description,
condition: fromEbayCondition(variants[0]?.condition),
state: { type: stateType },
price,
currency,
url,
varients,
};
}
const offer = ebayItem._offers?.[0];
const price = offer?.pricingSummary?.price?.value
? parseFloat(offer.pricingSummary.price.value)
: undefined;
const currency = offer?.pricingSummary?.price?.currency || undefined;
const stateType = resolveOfferState(ebayItem._offers || []);
const publishedListingId = parseEbayItemId(offer?.listingId || offer?.listing?.listingId);
const listingId = publishedListingId || ebayItem.sku;
const url = getEbayItemUrl(marketplace, publishedListingId);
const varients = [buildVarientEntry(ebayItem, ebayItem._offers || [])];
return {
externalReference: listingId,
title: ebayItem.product?.title || ebayItem.sku,
description: ebayItem.product?.description,
condition: fromEbayCondition(ebayItem.condition),
state: { type: stateType },
price,
currency,
url,
varients,
};
}