All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
397 lines
13 KiB
JavaScript
397 lines
13 KiB
JavaScript
import mongoose from 'mongoose';
|
|
import { stockLocationModel } from '../../../database/schemas/inventory/stocklocation.schema.js';
|
|
import { FARMCONTROL_GB_SUBDIVISION_STATE, resolveEbayCountry } from './countryCodes.js';
|
|
import { syncProductCategory } from './categories.js';
|
|
import { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
|
|
import { makeRequest, logger } from './shared.js';
|
|
|
|
const WAREHOUSE_ADDRESS_DEFAULTS = {
|
|
GB: { city: 'London', stateOrProvince: 'England', postalCode: 'SW1A 1AA' },
|
|
US: { city: 'New York', stateOrProvince: 'NY', postalCode: '10001' },
|
|
AU: { city: 'Sydney', stateOrProvince: 'NSW', postalCode: '2000' },
|
|
CA: { city: 'Toronto', stateOrProvince: 'ON', postalCode: 'M5H 2N2' },
|
|
DE: { city: 'Berlin', stateOrProvince: 'Berlin', postalCode: '10115' },
|
|
FR: { city: 'Paris', stateOrProvince: 'Île-de-France', postalCode: '75001' },
|
|
};
|
|
|
|
const LISTING_STATUS_MAP = {
|
|
ACTIVE: 'active',
|
|
OUT_OF_STOCK: 'inactive',
|
|
ENDED: 'inactive',
|
|
PUBLISHED: 'active',
|
|
UNPUBLISHED: 'draft',
|
|
};
|
|
|
|
function isPopulatedStockLocation(stockLocation) {
|
|
if (!stockLocation || typeof stockLocation !== 'object') return false;
|
|
if (stockLocation instanceof mongoose.Types.ObjectId) return false;
|
|
return stockLocation.name != null || stockLocation.address != null;
|
|
}
|
|
|
|
async function ensureListingStockLocation(listing) {
|
|
const stockLocationRef = listing?.stockLocation;
|
|
if (!stockLocationRef || isPopulatedStockLocation(stockLocationRef)) {
|
|
return listing;
|
|
}
|
|
|
|
const stockLocationId = stockLocationRef._id || stockLocationRef;
|
|
const stockLocation = await stockLocationModel.findById(stockLocationId).lean();
|
|
if (!stockLocation) throw new Error('Listing stock location not found.');
|
|
return { ...listing, stockLocation };
|
|
}
|
|
|
|
export function resolveStockLocationCountryCode(listing) {
|
|
return resolveStockLocationEbayCountry(listing)?.ebayCountryCode ?? null;
|
|
}
|
|
|
|
export function resolveStockLocationEbayCountry(listing) {
|
|
const stockLocation = listing?.stockLocation;
|
|
if (!isPopulatedStockLocation(stockLocation)) return null;
|
|
try {
|
|
return resolveEbayCountry(stockLocation.address?.country);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveMerchantLocationKey(listing) {
|
|
const stockLocationId = listing?.stockLocation?._id || listing?.stockLocation;
|
|
return stockLocationId ? `fc-${String(stockLocationId)}`.slice(0, 36) : null;
|
|
}
|
|
|
|
function buildEbayWarehouseAddress(addr, ebayCountryCode, farmControlCountryCode) {
|
|
const address = { country: ebayCountryCode };
|
|
|
|
if (addr?.addressLine1) address.addressLine1 = addr.addressLine1;
|
|
if (addr?.addressLine2) address.addressLine2 = addr.addressLine2;
|
|
if (addr?.city) address.city = addr.city;
|
|
if (addr?.state) address.stateOrProvince = addr.state;
|
|
if (addr?.postcode) address.postalCode = addr.postcode;
|
|
|
|
if (
|
|
!address.stateOrProvince &&
|
|
farmControlCountryCode &&
|
|
FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode]
|
|
) {
|
|
address.stateOrProvince = FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode];
|
|
}
|
|
|
|
if (!address.postalCode && !(address.city && address.stateOrProvince)) {
|
|
const defaults = WAREHOUSE_ADDRESS_DEFAULTS[ebayCountryCode];
|
|
if (defaults) {
|
|
Object.assign(address, defaults);
|
|
if (farmControlCountryCode && FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode]) {
|
|
address.stateOrProvince = FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode];
|
|
}
|
|
} else {
|
|
address.city = address.city || 'Unknown';
|
|
address.stateOrProvince = address.stateOrProvince || ebayCountryCode;
|
|
address.postalCode = address.postalCode || '00000';
|
|
}
|
|
}
|
|
|
|
if (!address.country) {
|
|
throw new Error('eBay warehouse address requires a country code.');
|
|
}
|
|
return address;
|
|
}
|
|
|
|
function buildWarehouseLocationBody(listing, ebayCountryCode, farmControlCountryCode) {
|
|
const stockLocation = listing.stockLocation;
|
|
return {
|
|
location: {
|
|
address: buildEbayWarehouseAddress(
|
|
stockLocation?.address || {},
|
|
ebayCountryCode,
|
|
farmControlCountryCode
|
|
),
|
|
},
|
|
locationTypes: ['WAREHOUSE'],
|
|
name: stockLocation?.name || `FarmControl ${ebayCountryCode}`,
|
|
};
|
|
}
|
|
|
|
export async function ensureMerchantLocation(marketplace, listing) {
|
|
const listingWithStockLocation = await ensureListingStockLocation(listing);
|
|
const countryResolved = resolveEbayCountry(
|
|
listingWithStockLocation.stockLocation?.address?.country
|
|
);
|
|
if (!countryResolved) {
|
|
throw new Error(
|
|
'Listing stock location address must include a country before syncing or publishing on eBay.'
|
|
);
|
|
}
|
|
|
|
const merchantLocationKey = resolveMerchantLocationKey(listingWithStockLocation);
|
|
if (!merchantLocationKey) {
|
|
throw new Error('Listing must have a stock location before syncing or publishing on eBay.');
|
|
}
|
|
|
|
const locationBody = buildWarehouseLocationBody(
|
|
listingWithStockLocation,
|
|
countryResolved.ebayCountryCode,
|
|
countryResolved.farmControlCountryCode
|
|
);
|
|
listing.stockLocation = listingWithStockLocation.stockLocation;
|
|
const locationPath = `/sell/inventory/v1/location/${encodeURIComponent(merchantLocationKey)}`;
|
|
const existing = await makeRequest({
|
|
marketplace,
|
|
path: locationPath,
|
|
acceptableStatuses: [404],
|
|
});
|
|
|
|
if (existing) {
|
|
await makeRequest({
|
|
marketplace,
|
|
method: 'POST',
|
|
path: `${locationPath}/update_location_details`,
|
|
body: { location: locationBody.location },
|
|
});
|
|
} else {
|
|
await makeRequest({
|
|
marketplace,
|
|
method: 'POST',
|
|
path: locationPath,
|
|
body: locationBody,
|
|
});
|
|
}
|
|
return merchantLocationKey;
|
|
}
|
|
|
|
function applyMarketplaceOfferDefaults(
|
|
offer,
|
|
marketplace,
|
|
merchantLocationKey,
|
|
fulfillmentPolicyId
|
|
) {
|
|
offer.merchantLocationKey = merchantLocationKey;
|
|
const config = marketplace.config || {};
|
|
const listingPolicies = {};
|
|
if (fulfillmentPolicyId || config.fulfillmentPolicyId) {
|
|
listingPolicies.fulfillmentPolicyId = fulfillmentPolicyId || config.fulfillmentPolicyId;
|
|
}
|
|
if (config.paymentPolicyId) listingPolicies.paymentPolicyId = config.paymentPolicyId;
|
|
if (config.returnPolicyId) listingPolicies.returnPolicyId = config.returnPolicyId;
|
|
if (Object.keys(listingPolicies).length) offer.listingPolicies = listingPolicies;
|
|
}
|
|
|
|
function mapVarientToInventoryItem(varient, listing) {
|
|
const item = {
|
|
product: { title: listing.title || varient._reference || '' },
|
|
availability: {
|
|
shipToLocationAvailability: { quantity: varient.inventory ?? 0 },
|
|
},
|
|
};
|
|
if (listing.description) item.product.description = listing.description;
|
|
if (listing.imageUrls?.length) item.product.imageUrls = listing.imageUrls;
|
|
return item;
|
|
}
|
|
|
|
function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) {
|
|
const offer = {
|
|
sku: varient._reference,
|
|
marketplaceId: marketplace.config?.marketplaceId || 'EBAY_GB',
|
|
format: 'FIXED_PRICE',
|
|
};
|
|
applyMarketplaceOfferDefaults(
|
|
offer,
|
|
marketplace,
|
|
merchantLocationKey,
|
|
listing.fulfillmentPolicyId
|
|
);
|
|
|
|
const price = varient.price ?? listing.price;
|
|
if (price != null) {
|
|
offer.pricingSummary = {
|
|
price: {
|
|
value: String(price),
|
|
currency: varient.currency || listing.currency || 'GBP',
|
|
},
|
|
};
|
|
}
|
|
if (listing.categoryId) offer.categoryId = String(listing.categoryId);
|
|
return offer;
|
|
}
|
|
|
|
export async function upsertInventoryItem(marketplace, varient, listing) {
|
|
const inventoryItem = mapVarientToInventoryItem(varient, listing);
|
|
const result = await makeRequest({
|
|
marketplace,
|
|
method: 'PUT',
|
|
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(varient._reference)}`,
|
|
body: inventoryItem,
|
|
});
|
|
logger.debug('inventoryItem', inventoryItem);
|
|
logger.debug('result', result);
|
|
}
|
|
|
|
export async function fetchOffers(marketplace, sku) {
|
|
try {
|
|
const data = await makeRequest({
|
|
marketplace,
|
|
path: '/sell/inventory/v1/offer',
|
|
params: { sku, limit: 200 },
|
|
acceptableStatuses: [404],
|
|
});
|
|
return data?.offers || [];
|
|
} catch (err) {
|
|
logger.debug(`No offers found for SKU ${sku}: ${err.message}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function upsertOrCreateOffer(marketplace, varient, listing) {
|
|
const merchantLocationKey = await ensureMerchantLocation(marketplace, listing);
|
|
const offers = await fetchOffers(marketplace, varient._reference);
|
|
const existingOffer = offers[0];
|
|
const price = varient.price ?? listing.price;
|
|
|
|
if (existingOffer?.offerId) {
|
|
const offerUpdate = { merchantLocationKey };
|
|
if (price != null) {
|
|
offerUpdate.pricingSummary = {
|
|
price: {
|
|
value: String(price),
|
|
currency: varient.currency || listing.currency || 'GBP',
|
|
},
|
|
};
|
|
}
|
|
if (listing.categoryId) offerUpdate.categoryId = String(listing.categoryId);
|
|
if (listing.fulfillmentPolicyId) {
|
|
offerUpdate.listingPolicies = {
|
|
...(existingOffer.listingPolicies || {}),
|
|
fulfillmentPolicyId: String(listing.fulfillmentPolicyId),
|
|
};
|
|
}
|
|
const body = { ...existingOffer, ...offerUpdate };
|
|
await makeRequest({
|
|
marketplace,
|
|
method: 'PUT',
|
|
path: `/sell/inventory/v1/offer/${existingOffer.offerId}`,
|
|
body,
|
|
});
|
|
logger.debug('offerUpdate', body);
|
|
return existingOffer;
|
|
}
|
|
|
|
const offerBody = mapVarientToOffer(varient, listing, marketplace, merchantLocationKey);
|
|
const result = await makeRequest({
|
|
marketplace,
|
|
method: 'POST',
|
|
path: '/sell/inventory/v1/offer',
|
|
body: offerBody,
|
|
});
|
|
logger.debug('offerBody', offerBody);
|
|
return result;
|
|
}
|
|
|
|
export async function publishOfferById(marketplace, offerId) {
|
|
if (!offerId) throw new Error('offerId is required to publish an offer');
|
|
return makeRequest({
|
|
marketplace,
|
|
method: 'POST',
|
|
path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/publish`,
|
|
});
|
|
}
|
|
|
|
export async function withdrawOfferById(marketplace, offerId) {
|
|
if (!offerId) throw new Error('offerId is required to withdraw an offer');
|
|
return makeRequest({
|
|
marketplace,
|
|
method: 'POST',
|
|
path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/withdraw`,
|
|
});
|
|
}
|
|
|
|
export async function syncOfferAndMaybePublish(marketplace, listing, varient) {
|
|
const offerResult = await upsertOrCreateOffer(marketplace, varient, listing);
|
|
if (offerResult?.offerId && listing.state?.type === 'active') {
|
|
try {
|
|
const publishResult = await publishOfferById(marketplace, offerResult.offerId);
|
|
if (publishResult?.listingId) {
|
|
return `https://www.ebay.com/itm/${publishResult.listingId}`;
|
|
}
|
|
} catch (err) {
|
|
logger.warn(
|
|
`Created offer but failed to publish for varient ${varient._reference}: ${err.message}`
|
|
);
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
export async function publishOfferForSku(marketplace, sku, listing) {
|
|
if (!sku) throw new Error('SKU (_reference) is required to publish an offer');
|
|
if (!listing) {
|
|
throw new Error(
|
|
'Listing is required to publish an eBay offer (stock location address is used for item location).'
|
|
);
|
|
}
|
|
|
|
const merchantLocationKey = await ensureMerchantLocation(marketplace, listing);
|
|
const category = await syncProductCategory(marketplace, listing);
|
|
const fulfillmentPolicy = await syncFulfillmentPolicy(marketplace, listing);
|
|
if (!category?.categoryId) {
|
|
throw new Error(
|
|
`Listing "${listing._reference || sku}" must have a product with a product category before publishing on eBay.`
|
|
);
|
|
}
|
|
|
|
const existingOffer = (await fetchOffers(marketplace, sku))[0];
|
|
if (!existingOffer?.offerId) {
|
|
throw new Error(
|
|
`No eBay offer exists for SKU "${sku}". Create or sync the listing so an offer exists before publishing.`
|
|
);
|
|
}
|
|
|
|
await makeRequest({
|
|
marketplace,
|
|
method: 'PUT',
|
|
path: `/sell/inventory/v1/offer/${existingOffer.offerId}`,
|
|
body: {
|
|
...existingOffer,
|
|
merchantLocationKey,
|
|
categoryId: String(category.categoryId),
|
|
listingPolicies: {
|
|
...(existingOffer.listingPolicies || {}),
|
|
fulfillmentPolicyId: fulfillmentPolicy.fulfillmentPolicyId,
|
|
},
|
|
},
|
|
});
|
|
|
|
const publishResult = await publishOfferById(marketplace, existingOffer.offerId);
|
|
return { offerId: existingOffer.offerId, listingId: publishResult?.listingId };
|
|
}
|
|
|
|
export async function withdrawOfferForSku(marketplace, sku) {
|
|
if (!sku) throw new Error('SKU (_reference) is required to withdraw an offer');
|
|
const existingOffer = (await fetchOffers(marketplace, sku))[0];
|
|
if (!existingOffer?.offerId) throw new Error(`No eBay offer exists for SKU "${sku}".`);
|
|
await withdrawOfferById(marketplace, existingOffer.offerId);
|
|
return { offerId: existingOffer.offerId };
|
|
}
|
|
|
|
export function resolveOfferState(offers) {
|
|
let stateType = 'draft';
|
|
for (const offer of offers) {
|
|
if (offer?.status && LISTING_STATUS_MAP[offer.status]) {
|
|
const mapped = LISTING_STATUS_MAP[offer.status];
|
|
if (mapped === 'active') return 'active';
|
|
if (mapped !== 'draft') stateType = mapped;
|
|
}
|
|
}
|
|
return stateType;
|
|
}
|
|
|
|
export function buildVarientEntry(item, offers) {
|
|
const offer = offers?.[0];
|
|
return {
|
|
_reference: item.sku,
|
|
price: offer?.pricingSummary?.price?.value
|
|
? parseFloat(offer.pricingSummary.price.value)
|
|
: undefined,
|
|
currency: offer?.pricingSummary?.price?.currency || undefined,
|
|
state: { type: resolveOfferState(offers || []) },
|
|
};
|
|
}
|