farmcontrol-api/src/integrations/marketplaceSync.js
Tom Butcher e98828bfc7
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
Update configuration and enhance marketplace integration with new policies
This commit modifies the log level in the configuration file from "trace" to "debug" for improved logging clarity. It also introduces new routes for fulfillment, return, and payment policies in the index file, enhancing the marketplace integration capabilities. Additionally, the initialization process is updated to start the marketplace worker, ensuring that the application can handle marketplace operations effectively. Various schemas are updated to include new fields and relationships for policies, improving the overall functionality and flexibility of the marketplace management system.
2026-08-29 00:47:55 +01:00

484 lines
15 KiB
JavaScript

import { clientModel } from '../database/schemas/sales/client.schema.js';
import { salesOrderModel } from '../database/schemas/sales/salesorder.schema.js';
import { listingModel } from '../database/schemas/sales/listing.schema.js';
import { listingVarientModel } from '../database/schemas/sales/listingvarient.schema.js';
import { marketplaceModel } from '../database/schemas/sales/marketplace.schema.js';
import { orderItemModel } from '../database/schemas/inventory/orderitem.schema.js';
import { shipmentModel } from '../database/schemas/inventory/shipment.schema.js';
import { marketplaceEventModel } from '../database/schemas/sales/marketplaceevent.schema.js';
import { productSkuModel } from '../database/schemas/management/productsku.schema.js';
import { productModel } from '../database/schemas/management/product.schema.js';
import { editObject, newObject } from '../database/database.js';
import { marketplaceActor, marketplaceSku } from './marketplaces/ids.js';
import config from '../config.js';
import log4js from 'log4js';
const logger = log4js.getLogger('Marketplace Sync');
logger.level = config.server.logLevel;
function externalIdOf(mapped = {}) {
return mapped.externalReference || mapped.externalId;
}
function stateType(mapped) {
return mapped?.state?.type || mapped?.state;
}
async function upsertClient(marketplace, clientData, actor) {
if (!clientData) return null;
const marketplaceId = marketplace._id;
let client = null;
const externalReference = externalIdOf(clientData);
if (externalReference) {
client = await clientModel.findOne({ marketplace: marketplaceId, externalReference });
}
if (!client && clientData.email) {
client = await clientModel.findOne({ marketplace: marketplaceId, email: clientData.email });
}
if (!client && clientData.name) {
client = await clientModel.findOne({ marketplace: marketplaceId, name: clientData.name });
}
const payload = {
...clientData,
marketplace: marketplaceId,
active: true,
...(externalReference ? { externalReference } : {}),
};
delete payload.externalId;
if (client) {
return editObject({
model: clientModel,
id: client._id,
updateData: payload,
user: actor,
});
}
return newObject({
model: clientModel,
newData: payload,
user: actor,
});
}
async function upsertOrderItems(salesOrder, externalOrder, provider, actor) {
if (typeof provider.mapOrderLineItems !== 'function') return;
const lines = provider.mapOrderLineItems(externalOrder) || [];
for (const line of lines) {
const lineRef = externalIdOf(line);
if (!lineRef) continue;
let listingVarient = null;
if (line.sku) {
listingVarient = await listingVarientModel.findOne({
$or: [{ externalReference: line.sku }, { _reference: line.sku }],
});
}
const payload = {
orderType: 'salesOrder',
order: salesOrder._id,
name: line.name || line.sku || 'Marketplace item',
itemType: line.itemType || 'product',
quantity: line.quantity || 1,
itemAmount: line.itemAmount ?? 0,
totalAmount: line.totalAmount ?? 0,
totalAmountWithTax: line.totalAmountWithTax ?? line.totalAmount ?? 0,
externalReference: lineRef,
listing: listingVarient?.listing,
listingVarient: listingVarient?._id,
item: listingVarient?.product,
sku: listingVarient?.productSku,
state: { type: stateType(salesOrder) === 'cancelled' ? 'cancelled' : 'ordered' },
};
const existing = await orderItemModel.findOne({
order: salesOrder._id,
externalReference: lineRef,
});
if (existing) {
await editObject({
model: orderItemModel,
id: existing._id,
updateData: payload,
user: actor,
});
} else {
await newObject({
model: orderItemModel,
newData: payload,
user: actor,
});
}
}
}
async function upsertOrderShipments(salesOrder, externalOrder, provider, actor) {
if (typeof provider.mapOrderShipments !== 'function') return;
const shipments = provider.mapOrderShipments(externalOrder) || [];
for (const mapped of shipments) {
const shipmentRef = externalIdOf(mapped);
if (!shipmentRef) continue;
const payload = {
orderType: 'salesOrder',
order: salesOrder._id,
trackingNumber: mapped.trackingNumber || '',
amount: mapped.amount ?? 0,
amountWithTax: mapped.amountWithTax ?? mapped.amount ?? 0,
externalReference: shipmentRef,
state: mapped.state || { type: 'planned' },
shippedAt: mapped.shippedAt,
};
const existing = await shipmentModel.findOne({
order: salesOrder._id,
externalReference: shipmentRef,
});
if (existing) {
await editObject({
model: shipmentModel,
id: existing._id,
updateData: payload,
user: actor,
});
} else {
await newObject({
model: shipmentModel,
newData: payload,
user: actor,
});
}
}
}
export async function upsertExternalOrder(marketplace, provider, externalOrder, actor) {
const mapped = provider.mapOrderToSalesOrder(externalOrder);
const clientData = provider.mapBuyerToClient(externalOrder);
const externalReference = externalIdOf(mapped);
if (!externalReference) {
throw new Error('Mapped sales order is missing externalReference');
}
const client = await upsertClient(marketplace, clientData, actor);
const existingOrder = await salesOrderModel.findOne({
marketplace: marketplace._id,
externalReference,
});
const lifecycle = {};
const type = mapped.state?.type;
if (type === 'confirmed' || type === 'shipped' || type === 'delivered') {
lifecycle.confirmedAt = existingOrder?.confirmedAt || new Date();
}
if (type === 'cancelled') {
lifecycle.cancelledAt = existingOrder?.cancelledAt || new Date();
}
const orderPayload = {
client: client?._id,
marketplace: marketplace._id,
externalReference,
state: mapped.state,
totalAmount: mapped.totalAmount,
totalAmountWithTax: mapped.totalAmountWithTax,
shippingAmount: mapped.shippingAmount,
shippingAmountWithTax: mapped.shippingAmountWithTax,
grandTotalAmount: mapped.grandTotalAmount,
totalTaxAmount: mapped.totalTaxAmount,
...lifecycle,
};
let salesOrder = existingOrder;
if (existingOrder) {
salesOrder = await editObject({
model: salesOrderModel,
id: existingOrder._id,
updateData: orderPayload,
user: actor,
});
} else {
salesOrder = await newObject({
model: salesOrderModel,
newData: orderPayload,
user: actor,
});
}
await upsertOrderItems(salesOrder, externalOrder, provider, actor);
await upsertOrderShipments(salesOrder, externalOrder, provider, actor);
return { salesOrder, action: existingOrder ? 'updated' : 'created', externalReference };
}
async function matchProductSku(mapped) {
const skuCodes = [
mapped.externalReference,
...(mapped.varients || []).map((v) => v.externalReference || v._reference),
].filter(Boolean);
for (const code of skuCodes) {
const sku = await productSkuModel.findOne({
$or: [{ _reference: code }, { barcode: code }],
});
if (sku) return sku;
}
return null;
}
async function upsertInboundListing(marketplace, mapped, actor) {
const externalReference = externalIdOf(mapped);
if (!externalReference) {
logger.warn('Skipping inbound listing without externalReference');
return { action: 'skipped' };
}
const defaults = marketplace.config?.defaults || {};
const productSku = await matchProductSku(mapped);
const product = productSku
? await productModel.findById(productSku.product).lean()
: null;
const existing = await listingModel.findOne({
marketplace: marketplace._id,
externalReference,
});
const payload = {
title: mapped.title,
url: mapped.url,
price: mapped.price,
currency: mapped.currency,
description: mapped.description,
externalReference,
lastSyncedAt: new Date(),
state: mapped.state || { type: 'active' },
marketplace: marketplace._id,
};
if (product) {
payload.product = product._id;
payload.vendor = product.vendor;
}
let listing = existing;
if (existing) {
listing = await editObject({
model: listingModel,
id: existing._id,
updateData: payload,
user: actor,
});
} else {
const vendor = payload.vendor || defaults.vendor;
const stockLocation = defaults.stockLocation;
const courierServices = defaults.courierServices || [];
if (!vendor || !stockLocation) {
logger.warn(
`Skipping inbound listing ${externalReference}: marketplace defaults.vendor and defaults.stockLocation are required to create listings`
);
return { action: 'skipped', externalReference };
}
listing = await newObject({
model: listingModel,
newData: {
...payload,
vendor,
stockLocation,
courierServices,
},
user: actor,
});
}
for (const mappedVarient of mapped.varients || []) {
const varientRef = mappedVarient.externalReference || mappedVarient._reference;
if (!varientRef) continue;
const varientPayload = {
listing: listing._id,
externalReference: varientRef,
price: mappedVarient.price,
currency: mappedVarient.currency,
state: mappedVarient.state || mapped.state || { type: 'active' },
lastSyncedAt: new Date(),
product: product?._id || listing.product,
productSku: productSku?._id,
...(Array.isArray(mappedVarient.aspects) ? { aspects: mappedVarient.aspects } : {}),
};
const existingVarient = await listingVarientModel.findOne({
listing: listing._id,
externalReference: varientRef,
});
if (existingVarient) {
await editObject({
model: listingVarientModel,
id: existingVarient._id,
updateData: varientPayload,
user: actor,
});
} else {
await newObject({
model: listingVarientModel,
newData: varientPayload,
user: actor,
});
}
}
return { action: existing ? 'updated' : 'created', listing };
}
export async function importExternalItems(marketplace, provider, actor) {
if (typeof provider.syncItems !== 'function' || typeof provider.mapProductToListing !== 'function') {
return [];
}
const externalItems = await provider.syncItems(marketplace);
const results = [];
for (const item of externalItems || []) {
try {
const mapped = provider.mapProductToListing(item, marketplace);
const result = await upsertInboundListing(marketplace, mapped, actor);
results.push(result);
} catch (err) {
logger.warn(`Failed to import marketplace listing: ${err.message}`);
results.push({ action: 'error', error: err.message });
}
}
return results;
}
export async function recordMarketplaceEvent(marketplace, classified, actor) {
const externalReference =
classified.notificationId ||
`${classified.action}:${classified.orderId || classified.itemId || classified.topic || Date.now()}`;
const existing = await marketplaceEventModel.findOne({
marketplace: marketplace._id,
externalReference,
});
if (existing) {
return existing;
}
return newObject({
model: marketplaceEventModel,
newData: {
marketplace: marketplace._id,
externalReference,
topic: classified.topic || classified.action || 'unknown',
status: 'received',
},
user: actor,
});
}
export async function markMarketplaceEvent(event, status, actor, error) {
if (!event?._id) return;
await editObject({
model: marketplaceEventModel,
id: event._id,
updateData: {
status,
processedAt: status === 'processed' ? new Date() : event.processedAt,
error: error || undefined,
},
user: actor,
});
}
export async function applyWebhookAction(marketplace, provider, classified, actor) {
const event = await recordMarketplaceEvent(marketplace, classified, actor);
if (event.status === 'processed') {
return { ...classified, duplicate: true };
}
try {
switch (classified.action) {
case 'orderCreate':
case 'orderUpdate':
case 'orderCancel': {
if (classified.orderId && typeof provider.getOrder === 'function') {
const order = await provider.getOrder(marketplace, classified.orderId);
await upsertExternalOrder(marketplace, provider, order, actor);
}
break;
}
case 'productUpdate':
case 'productEnded': {
await importExternalItems(marketplace, provider, actor);
break;
}
case 'accountDeletion': {
if (classified.userId) {
const client = await clientModel.findOne({
marketplace: marketplace._id,
$or: [{ externalReference: classified.userId }, { name: classified.userId }],
});
if (client) {
await editObject({
model: clientModel,
id: client._id,
updateData: {
name: 'Deleted marketplace user',
email: '',
phone: '',
address: {},
},
user: actor,
});
}
}
break;
}
case 'disconnect': {
await editObject({
model: marketplaceModel,
id: marketplace._id,
updateData: { connected: false, state: { type: 'disconnected' } },
user: actor,
});
break;
}
default:
break;
}
await markMarketplaceEvent(event, 'processed', actor);
} catch (err) {
await markMarketplaceEvent(event, 'failed', actor, err.message);
throw err;
}
return classified;
}
export async function pushShipmentFulfillment(marketplace, provider, shipment, actor) {
if (typeof provider.createShippingFulfillment !== 'function') {
return;
}
const orderId = shipment.order?._id || shipment.order;
const salesOrder = await salesOrderModel.findById(orderId).lean();
if (!salesOrder?.externalReference) {
return;
}
const orderItems = await orderItemModel
.find({ order: salesOrder._id, shipment: shipment._id, orderType: 'salesOrder' })
.lean();
await provider.createShippingFulfillment(marketplace, {
orderId: salesOrder.externalReference,
trackingNumber: shipment.trackingNumber,
shippingCarrierCode: shipment.courierService?._reference || shipment.courierService?.code,
lineItems: orderItems.map((item) => ({
lineItemId: item.externalReference,
quantity: item.quantity,
})),
});
if (shipment._id && !shipment.externalReference) {
await editObject({
model: shipmentModel,
id: shipment._id,
updateData: { lastSyncedAt: new Date() },
user: actor,
});
}
}
export { marketplaceActor, marketplaceSku };