Compare commits
3 Commits
250c404408
...
57f057e3aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 57f057e3aa | |||
| d3c662a9ec | |||
| 1b858d8814 |
@ -20,6 +20,11 @@ const filamentStockSchema = new Schema(
|
|||||||
gross: { type: Number, required: true },
|
gross: { type: Number, required: true },
|
||||||
},
|
},
|
||||||
filamentSku: { type: mongoose.Schema.Types.ObjectId, ref: 'filamentSku', required: true },
|
filamentSku: { type: mongoose.Schema.Types.ObjectId, ref: 'filamentSku', required: true },
|
||||||
|
stockLocation: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'stockLocation',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,6 +12,11 @@ const partStockSchema = new Schema(
|
|||||||
progress: { type: Number, required: false },
|
progress: { type: Number, required: false },
|
||||||
},
|
},
|
||||||
partSku: { type: mongoose.Schema.Types.ObjectId, ref: 'partSku', required: true },
|
partSku: { type: mongoose.Schema.Types.ObjectId, ref: 'partSku', required: true },
|
||||||
|
stockLocation: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'stockLocation',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
currentQuantity: { type: Number, required: true },
|
currentQuantity: { type: Number, required: true },
|
||||||
sourceType: { type: String, required: true },
|
sourceType: { type: String, required: true },
|
||||||
source: { type: Schema.Types.ObjectId, refPath: 'sourceType', required: true },
|
source: { type: Schema.Types.ObjectId, refPath: 'sourceType', required: true },
|
||||||
|
|||||||
@ -19,6 +19,11 @@ const productStockSchema = new Schema(
|
|||||||
},
|
},
|
||||||
postedAt: { type: Date, required: false },
|
postedAt: { type: Date, required: false },
|
||||||
productSku: { type: mongoose.Schema.Types.ObjectId, ref: 'productSku', required: true },
|
productSku: { type: mongoose.Schema.Types.ObjectId, ref: 'productSku', required: true },
|
||||||
|
stockLocation: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'stockLocation',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
currentQuantity: { type: Number, required: true },
|
currentQuantity: { type: Number, required: true },
|
||||||
partStocks: [partStockUsageSchema],
|
partStocks: [partStockUsageSchema],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -25,7 +25,7 @@ const stockEventSchema = new Schema(
|
|||||||
ownerType: {
|
ownerType: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
enum: ['user', 'subJob', 'stockAudit'],
|
enum: ['user', 'subJob', 'stockAudit', 'stockTransfer'],
|
||||||
},
|
},
|
||||||
timestamp: { type: Date, default: Date.now },
|
timestamp: { type: Date, default: Date.now },
|
||||||
},
|
},
|
||||||
|
|||||||
39
src/database/schemas/inventory/stocklocation.schema.js
Normal file
39
src/database/schemas/inventory/stocklocation.schema.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const addressSchema = new Schema({
|
||||||
|
building: { required: false, type: String },
|
||||||
|
addressLine1: { required: false, type: String },
|
||||||
|
addressLine2: { required: false, type: String },
|
||||||
|
city: { required: false, type: String },
|
||||||
|
state: { required: false, type: String },
|
||||||
|
postcode: { required: false, type: String },
|
||||||
|
country: { required: false, type: String },
|
||||||
|
});
|
||||||
|
|
||||||
|
const stockLocationSchema = new Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
address: { required: false, type: addressSchema },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
stockLocationSchema.statics.stats = async function () {
|
||||||
|
const total = await this.countDocuments({});
|
||||||
|
return { total: { count: total } };
|
||||||
|
};
|
||||||
|
|
||||||
|
stockLocationSchema.statics.history = async function () {
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
stockLocationSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
stockLocationSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const stockLocationModel = mongoose.model('stockLocation', stockLocationSchema);
|
||||||
71
src/database/schemas/inventory/stocktransfer.schema.js
Normal file
71
src/database/schemas/inventory/stocktransfer.schema.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const stockTransferLineSchema = new Schema(
|
||||||
|
{
|
||||||
|
fromStockType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
enum: ['filamentStock', 'partStock', 'productStock'],
|
||||||
|
},
|
||||||
|
fromStock: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
refPath: 'fromStockType',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
quantity: { type: Number, required: true },
|
||||||
|
toStockLocation: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'stockLocation',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
toStockType: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
enum: ['filamentStock', 'partStock', 'productStock'],
|
||||||
|
},
|
||||||
|
toStock: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
refPath: 'toStockType',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const stockTransferSchema = new Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
state: {
|
||||||
|
type: { type: String, required: true, default: 'draft' },
|
||||||
|
progress: { type: Number, required: false },
|
||||||
|
},
|
||||||
|
postedAt: { type: Date, required: false },
|
||||||
|
lines: { type: [stockTransferLineSchema], default: [] },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
stockTransferSchema.statics.stats = async function () {
|
||||||
|
const [draft, posted] = await Promise.all([
|
||||||
|
this.countDocuments({ 'state.type': 'draft' }),
|
||||||
|
this.countDocuments({ 'state.type': 'posted' }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
draft: { count: draft },
|
||||||
|
posted: { count: posted },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
stockTransferSchema.statics.history = async function () {
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
stockTransferSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
stockTransferSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const stockTransferModel = mongoose.model('stockTransfer', stockTransferSchema);
|
||||||
@ -56,6 +56,8 @@ const hostSchema = new mongoose.Schema(
|
|||||||
connectedAt: { required: false, type: Date },
|
connectedAt: { required: false, type: Date },
|
||||||
authCode: { type: { required: false, type: String } },
|
authCode: { type: { required: false, type: String } },
|
||||||
deviceInfo: { deviceInfoSchema },
|
deviceInfo: { deviceInfoSchema },
|
||||||
|
otp: { type: { required: false, type: String } },
|
||||||
|
otpExpiresAt: { required: false, type: Date },
|
||||||
files: [{ type: mongoose.Schema.Types.ObjectId, ref: 'file' }],
|
files: [{ type: mongoose.Schema.Types.ObjectId, ref: 'file' }],
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
|
|||||||
@ -31,19 +31,10 @@ partSchema.virtual('id').get(function () {
|
|||||||
partSchema.set('toJSON', { virtuals: true });
|
partSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
partSchema.statics.recalculate = async function (part, user) {
|
partSchema.statics.recalculate = async function (part, user) {
|
||||||
const orderItemModel = mongoose.model('orderItem');
|
const partSkuModel = mongoose.model('partSku');
|
||||||
const itemId = part._id;
|
const skus = await partSkuModel.find({ part: part._id }).select('_id').lean();
|
||||||
const draftOrderItems = await orderItemModel
|
for (const sku of skus) {
|
||||||
.find({
|
await partSkuModel.recalculate(sku, user);
|
||||||
'state.type': 'draft',
|
|
||||||
itemType: 'part',
|
|
||||||
item: itemId,
|
|
||||||
})
|
|
||||||
.populate('order')
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
for (const orderItem of draftOrderItems) {
|
|
||||||
await orderItemModel.recalculate(orderItem, user);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,7 @@ productSchema.statics.recalculate = async function (product, user) {
|
|||||||
for (const orderItem of draftOrderItems) {
|
for (const orderItem of draftOrderItems) {
|
||||||
await orderItemModel.recalculate(orderItem, user);
|
await orderItemModel.recalculate(orderItem, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create and export the model
|
// Create and export the model
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import { stockEventModel } from './inventory/stockevent.schema.js';
|
|||||||
import { stockAuditModel } from './inventory/stockaudit.schema.js';
|
import { stockAuditModel } from './inventory/stockaudit.schema.js';
|
||||||
import { partStockModel } from './inventory/partstock.schema.js';
|
import { partStockModel } from './inventory/partstock.schema.js';
|
||||||
import { productStockModel } from './inventory/productstock.schema.js';
|
import { productStockModel } from './inventory/productstock.schema.js';
|
||||||
|
import { stockLocationModel } from './inventory/stocklocation.schema.js';
|
||||||
|
import { stockTransferModel } from './inventory/stocktransfer.schema.js';
|
||||||
import { auditLogModel } from './management/auditlog.schema.js';
|
import { auditLogModel } from './management/auditlog.schema.js';
|
||||||
import { userModel } from './management/user.schema.js';
|
import { userModel } from './management/user.schema.js';
|
||||||
import { appPasswordModel } from './management/apppassword.schema.js';
|
import { appPasswordModel } from './management/apppassword.schema.js';
|
||||||
@ -39,6 +41,8 @@ import { invoiceModel } from './finance/invoice.schema.js';
|
|||||||
import { clientModel } from './sales/client.schema.js';
|
import { clientModel } from './sales/client.schema.js';
|
||||||
import { salesOrderModel } from './sales/salesorder.schema.js';
|
import { salesOrderModel } from './sales/salesorder.schema.js';
|
||||||
import { marketplaceModel } from './sales/marketplace.schema.js';
|
import { marketplaceModel } from './sales/marketplace.schema.js';
|
||||||
|
import { listingModel } from './sales/listing.schema.js';
|
||||||
|
import { listingVarientModel } from './sales/listingvarient.schema.js';
|
||||||
|
|
||||||
// Map prefixes to models and id fields
|
// Map prefixes to models and id fields
|
||||||
export const models = {
|
export const models = {
|
||||||
@ -155,6 +159,20 @@ export const models = {
|
|||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
label: 'Product Stock',
|
label: 'Product Stock',
|
||||||
},
|
},
|
||||||
|
SLN: {
|
||||||
|
model: stockLocationModel,
|
||||||
|
idField: '_id',
|
||||||
|
type: 'stockLocation',
|
||||||
|
referenceField: '_reference',
|
||||||
|
label: 'Stock Location',
|
||||||
|
},
|
||||||
|
STT: {
|
||||||
|
model: stockTransferModel,
|
||||||
|
idField: '_id',
|
||||||
|
type: 'stockTransfer',
|
||||||
|
referenceField: '_reference',
|
||||||
|
label: 'Stock Transfer',
|
||||||
|
},
|
||||||
ADL: {
|
ADL: {
|
||||||
model: auditLogModel,
|
model: auditLogModel,
|
||||||
idField: '_id',
|
idField: '_id',
|
||||||
@ -323,4 +341,18 @@ export const models = {
|
|||||||
label: 'Marketplace',
|
label: 'Marketplace',
|
||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
},
|
},
|
||||||
|
LST: {
|
||||||
|
model: listingModel,
|
||||||
|
idField: '_id',
|
||||||
|
type: 'listing',
|
||||||
|
label: 'Listing',
|
||||||
|
referenceField: '_reference',
|
||||||
|
},
|
||||||
|
LVR: {
|
||||||
|
model: listingVarientModel,
|
||||||
|
idField: '_id',
|
||||||
|
type: 'listingVarient',
|
||||||
|
label: 'Listing Varient',
|
||||||
|
referenceField: '_reference',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
44
src/database/schemas/sales/listing.schema.js
Normal file
44
src/database/schemas/sales/listing.schema.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const listingSchema = new Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
product: { type: Schema.Types.ObjectId, ref: 'product', required: false },
|
||||||
|
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||||
|
stockLocation: { type: Schema.Types.ObjectId, ref: 'stockLocation', required: true },
|
||||||
|
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||||
|
title: { type: String, required: false },
|
||||||
|
state: {
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
enum: ['draft', 'active', 'inactive', 'deleted', 'suspended', 'syncing'],
|
||||||
|
default: 'draft',
|
||||||
|
},
|
||||||
|
message: { type: String, required: false },
|
||||||
|
},
|
||||||
|
url: { type: String, required: false },
|
||||||
|
price: { type: Number, required: false },
|
||||||
|
currency: { type: String, required: false },
|
||||||
|
lastSyncedAt: { type: Date, required: false },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
listingSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
listingSchema.set('toJSON', {
|
||||||
|
virtuals: true,
|
||||||
|
transform(doc, ret) {
|
||||||
|
if (!ret.state && ret.status) {
|
||||||
|
ret.state = { type: ret.status, message: null };
|
||||||
|
}
|
||||||
|
if (ret.status) delete ret.status;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listingModel = mongoose.model('listing', listingSchema);
|
||||||
43
src/database/schemas/sales/listingvarient.schema.js
Normal file
43
src/database/schemas/sales/listingvarient.schema.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const listingVarientSchema = new Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
listing: { type: Schema.Types.ObjectId, ref: 'listing', required: true },
|
||||||
|
product: { type: Schema.Types.ObjectId, ref: 'product', required: false },
|
||||||
|
productSku: { type: Schema.Types.ObjectId, ref: 'productSku', required: false },
|
||||||
|
state: {
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
enum: ['draft', 'active', 'inactive', 'deleted', 'suspended', 'syncing'],
|
||||||
|
default: 'draft',
|
||||||
|
},
|
||||||
|
message: { type: String, required: false },
|
||||||
|
},
|
||||||
|
price: { type: Number, required: false },
|
||||||
|
currency: { type: String, required: false },
|
||||||
|
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||||
|
priceWithTax: { type: Number, required: false },
|
||||||
|
lastSyncedAt: { type: Date, required: false },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
listingVarientSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
listingVarientSchema.set('toJSON', {
|
||||||
|
virtuals: true,
|
||||||
|
transform(doc, ret) {
|
||||||
|
if (!ret.state && ret.status) {
|
||||||
|
ret.state = { type: ret.status, message: null };
|
||||||
|
}
|
||||||
|
if (ret.status) delete ret.status;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listingVarientModel = mongoose.model('listingVarient', listingVarientSchema);
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
|
import { editObject } from '../../database.js';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
|
|
||||||
const marketplaceSchema = new mongoose.Schema(
|
const marketplaceSchema = new mongoose.Schema(
|
||||||
@ -11,6 +12,16 @@ const marketplaceSchema = new mongoose.Schema(
|
|||||||
enum: ['ebay', 'etsy', 'tiktokShop'],
|
enum: ['ebay', 'etsy', 'tiktokShop'],
|
||||||
},
|
},
|
||||||
active: { required: true, type: Boolean, default: true },
|
active: { required: true, type: Boolean, default: true },
|
||||||
|
connected: { type: Boolean, required: true, default: false },
|
||||||
|
connectedAt: { type: Date, required: false },
|
||||||
|
state: {
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
enum: ['active', 'inactive', 'suspended', 'ready', 'offline', 'syncing'],
|
||||||
|
default: 'offline',
|
||||||
|
},
|
||||||
|
message: { type: String, required: false },
|
||||||
|
},
|
||||||
// Provider-specific API configuration (flexible for eBay, Etsy, TikTok Shop)
|
// Provider-specific API configuration (flexible for eBay, Etsy, TikTok Shop)
|
||||||
config: { type: mongoose.Schema.Types.Mixed, default: {} },
|
config: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||||
},
|
},
|
||||||
@ -21,6 +32,26 @@ marketplaceSchema.virtual('id').get(function () {
|
|||||||
return this._id;
|
return this._id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
marketplaceSchema.statics.recalculate = async function (marketplace, user) {
|
||||||
|
let stateType;
|
||||||
|
if (marketplace.active === false) {
|
||||||
|
stateType = 'inactive';
|
||||||
|
} else if (marketplace.connected === false) {
|
||||||
|
stateType = 'disconnected';
|
||||||
|
} else {
|
||||||
|
stateType = 'ready';
|
||||||
|
}
|
||||||
|
console.log('recalculating marketplace state', stateType);
|
||||||
|
marketplace.state = { type: stateType };
|
||||||
|
await editObject({
|
||||||
|
model: this,
|
||||||
|
id: marketplace._id,
|
||||||
|
updateData: { state: { type: stateType } },
|
||||||
|
user,
|
||||||
|
recalculate: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
marketplaceSchema.set('toJSON', { virtuals: true });
|
marketplaceSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
export const marketplaceModel = mongoose.model('marketplace', marketplaceSchema);
|
export const marketplaceModel = mongoose.model('marketplace', marketplaceSchema);
|
||||||
|
|||||||
@ -29,6 +29,8 @@ import {
|
|||||||
orderItemRoutes,
|
orderItemRoutes,
|
||||||
shipmentRoutes,
|
shipmentRoutes,
|
||||||
stockAuditRoutes,
|
stockAuditRoutes,
|
||||||
|
stockLocationRoutes,
|
||||||
|
stockTransferRoutes,
|
||||||
stockEventRoutes,
|
stockEventRoutes,
|
||||||
auditLogRoutes,
|
auditLogRoutes,
|
||||||
noteTypeRoutes,
|
noteTypeRoutes,
|
||||||
@ -47,6 +49,8 @@ import {
|
|||||||
clientRoutes,
|
clientRoutes,
|
||||||
salesOrderRoutes,
|
salesOrderRoutes,
|
||||||
marketplaceRoutes,
|
marketplaceRoutes,
|
||||||
|
listingRoutes,
|
||||||
|
listingVarientRoutes,
|
||||||
userNotifierRoutes,
|
userNotifierRoutes,
|
||||||
notificationRoutes,
|
notificationRoutes,
|
||||||
odataRoutes,
|
odataRoutes,
|
||||||
@ -150,6 +154,8 @@ app.use('/orderitems', orderItemRoutes);
|
|||||||
app.use('/shipments', shipmentRoutes);
|
app.use('/shipments', shipmentRoutes);
|
||||||
app.use('/stockevents', stockEventRoutes);
|
app.use('/stockevents', stockEventRoutes);
|
||||||
app.use('/stockaudits', stockAuditRoutes);
|
app.use('/stockaudits', stockAuditRoutes);
|
||||||
|
app.use('/stocklocations', stockLocationRoutes);
|
||||||
|
app.use('/stocktransfers', stockTransferRoutes);
|
||||||
app.use('/auditlogs', auditLogRoutes);
|
app.use('/auditlogs', auditLogRoutes);
|
||||||
app.use('/notetypes', noteTypeRoutes);
|
app.use('/notetypes', noteTypeRoutes);
|
||||||
app.use('/documentsizes', documentSizesRoutes);
|
app.use('/documentsizes', documentSizesRoutes);
|
||||||
@ -165,6 +171,8 @@ app.use('/payments', paymentRoutes);
|
|||||||
app.use('/clients', clientRoutes);
|
app.use('/clients', clientRoutes);
|
||||||
app.use('/salesorders', salesOrderRoutes);
|
app.use('/salesorders', salesOrderRoutes);
|
||||||
app.use('/marketplaces', marketplaceRoutes);
|
app.use('/marketplaces', marketplaceRoutes);
|
||||||
|
app.use('/listings', listingRoutes);
|
||||||
|
app.use('/listingvarients', listingVarientRoutes);
|
||||||
app.use('/notes', noteRoutes);
|
app.use('/notes', noteRoutes);
|
||||||
app.use('/usernotifiers', userNotifierRoutes);
|
app.use('/usernotifiers', userNotifierRoutes);
|
||||||
app.use('/notifications', notificationRoutes);
|
app.use('/notifications', notificationRoutes);
|
||||||
|
|||||||
163
src/integrations/marketplaces/ebay/auth.js
Normal file
163
src/integrations/marketplaces/ebay/auth.js
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import {
|
||||||
|
getApiBaseUrl,
|
||||||
|
getAuthorizeBaseUrl,
|
||||||
|
getScopes,
|
||||||
|
getScopesString,
|
||||||
|
getTokenExpiryDate,
|
||||||
|
isAccessTokenExpired,
|
||||||
|
getRequiredAuthConfig,
|
||||||
|
getBasicAuthHeader,
|
||||||
|
logger,
|
||||||
|
} from './shared.js';
|
||||||
|
|
||||||
|
const TOKEN_PATH = '/identity/v1/oauth2/token';
|
||||||
|
|
||||||
|
async function mintToken(marketplace, body) {
|
||||||
|
const response = await fetch(`${getApiBaseUrl(marketplace)}${TOKEN_PATH}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Authorization: getBasicAuthHeader(marketplace),
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(body).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || data.error) {
|
||||||
|
const message = data.error_description || data.error || response.statusText;
|
||||||
|
logger.error(`eBay token request failed: ${message}`);
|
||||||
|
throw new Error(`eBay token request failed: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthorizationUrl(marketplace, { state } = {}) {
|
||||||
|
const { clientId } = getRequiredAuthConfig(marketplace);
|
||||||
|
const { ruName, locale, prompt } = marketplace.config || {};
|
||||||
|
|
||||||
|
if (!ruName) {
|
||||||
|
throw new Error('eBay marketplace is missing required config (ruName)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL('/oauth2/authorize', getAuthorizeBaseUrl(marketplace));
|
||||||
|
url.searchParams.set('client_id', clientId);
|
||||||
|
url.searchParams.set('redirect_uri', ruName);
|
||||||
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('scope', getScopesString(marketplace));
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
}
|
||||||
|
if (locale) {
|
||||||
|
url.searchParams.set('locale', locale);
|
||||||
|
}
|
||||||
|
if (prompt) {
|
||||||
|
url.searchParams.set('prompt', prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeAuthorizationCode(marketplace, { code }) {
|
||||||
|
const { ruName } = marketplace.config || {};
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('Missing eBay authorization code');
|
||||||
|
}
|
||||||
|
if (!ruName) {
|
||||||
|
throw new Error('eBay marketplace is missing required config (ruName)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await mintToken(marketplace, {
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: ruName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
configUpdates: {
|
||||||
|
accessToken: tokenData.access_token,
|
||||||
|
accessTokenExpiresAt: getTokenExpiryDate(tokenData.expires_in).toISOString(),
|
||||||
|
refreshToken: tokenData.refresh_token || marketplace.config.refreshToken,
|
||||||
|
scopes: getScopes(marketplace),
|
||||||
|
tokenType: tokenData.token_type,
|
||||||
|
},
|
||||||
|
marketplaceUpdates: {
|
||||||
|
connected: true,
|
||||||
|
connectedAt: new Date(),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
expiresIn: tokenData.expires_in,
|
||||||
|
tokenType: tokenData.token_type,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAuth(marketplace) {
|
||||||
|
const { refreshToken } = marketplace.config || {};
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('eBay marketplace is missing required config (refreshToken)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await mintToken(marketplace, {
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
scope: getScopesString(marketplace),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
configUpdates: {
|
||||||
|
accessToken: tokenData.access_token,
|
||||||
|
accessTokenExpiresAt: getTokenExpiryDate(tokenData.expires_in).toISOString(),
|
||||||
|
refreshToken: tokenData.refresh_token || refreshToken,
|
||||||
|
scopes: getScopes(marketplace),
|
||||||
|
tokenType: tokenData.token_type,
|
||||||
|
lastTokenRefreshAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
expiresIn: tokenData.expires_in,
|
||||||
|
tokenType: tokenData.token_type,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureAuthenticatedMarketplace(marketplace) {
|
||||||
|
if (!isAccessTokenExpired(marketplace)) {
|
||||||
|
return { marketplace };
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = await refreshAuth(marketplace);
|
||||||
|
return {
|
||||||
|
marketplace: {
|
||||||
|
...marketplace,
|
||||||
|
config: {
|
||||||
|
...(marketplace.config || {}),
|
||||||
|
...authResult.configUpdates,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configUpdates: authResult.configUpdates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canVerifyWebhookSignature(marketplace) {
|
||||||
|
return !!marketplace.config?.verificationToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyWebhookSignature(marketplace, rawBody, signature) {
|
||||||
|
const verificationToken = marketplace.config?.verificationToken;
|
||||||
|
if (!verificationToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(rawBody + verificationToken)
|
||||||
|
.digest('base64');
|
||||||
|
|
||||||
|
try {
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/integrations/marketplaces/ebay/countryCodes.js
Normal file
102
src/integrations/marketplaces/ebay/countryCodes.js
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Maps FarmControl country codes (farmcontrol-ui/src/database/Countries.js)
|
||||||
|
* to eBay Sell Inventory API CountryCodeEnum values.
|
||||||
|
* @see https://developer.ebay.com/api-docs/sell/inventory/types/ba:CountryCodeEnum
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Set<string>} */
|
||||||
|
export const EBAY_COUNTRY_CODES = new Set([
|
||||||
|
'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AN', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ',
|
||||||
|
'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT',
|
||||||
|
'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR',
|
||||||
|
'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER',
|
||||||
|
'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL',
|
||||||
|
'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU', 'ID',
|
||||||
|
'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI',
|
||||||
|
'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV',
|
||||||
|
'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS',
|
||||||
|
'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR',
|
||||||
|
'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY',
|
||||||
|
'QA', 'RE', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL',
|
||||||
|
'SM', 'SN', 'SO', 'SR', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL',
|
||||||
|
'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE',
|
||||||
|
'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FarmControl-only codes (Countries.js) that must map to an eBay enum value.
|
||||||
|
*/
|
||||||
|
const FARMCONTROL_TO_EBAY = {
|
||||||
|
UK: 'GB',
|
||||||
|
'GB-ENG': 'GB',
|
||||||
|
'GB-NIR': 'GB',
|
||||||
|
'GB-SCT': 'GB',
|
||||||
|
'GB-UKM': 'GB',
|
||||||
|
'GB-WLS': 'GB',
|
||||||
|
'BQ-BO': 'BQ',
|
||||||
|
'BQ-SA': 'BQ',
|
||||||
|
'BQ-SE': 'BQ',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FarmControl codes with no eBay equivalent — sync/publish will fail with a clear message.
|
||||||
|
*/
|
||||||
|
const FARMCONTROL_UNSUPPORTED_ON_EBAY = new Set(['SS']);
|
||||||
|
|
||||||
|
/** State/province hints when a UK subdivision code is mapped to GB. */
|
||||||
|
export const FARMCONTROL_GB_SUBDIVISION_STATE = {
|
||||||
|
'GB-ENG': 'England',
|
||||||
|
'GB-NIR': 'Northern Ireland',
|
||||||
|
'GB-SCT': 'Scotland',
|
||||||
|
'GB-WLS': 'Wales',
|
||||||
|
'GB-UKM': 'England',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | null | undefined} farmControlCountryCode
|
||||||
|
* @returns {string | null} eBay CountryCodeEnum or null if missing/blank
|
||||||
|
*/
|
||||||
|
export function toEbayCountryCode(farmControlCountryCode) {
|
||||||
|
if (farmControlCountryCode == null || typeof farmControlCountryCode !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = farmControlCountryCode.trim().toUpperCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FARMCONTROL_UNSUPPORTED_ON_EBAY.has(normalized)) {
|
||||||
|
throw new Error(
|
||||||
|
`Country "${normalized}" is not supported by eBay. Choose a different stock location country.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = FARMCONTROL_TO_EBAY[normalized];
|
||||||
|
if (mapped) {
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EBAY_COUNTRY_CODES.has(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Country "${normalized}" is not supported by eBay. Use a country from the stock location address list.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | null | undefined} farmControlCountryCode
|
||||||
|
* @returns {{ ebayCountryCode: string, farmControlCountryCode: string } | null}
|
||||||
|
*/
|
||||||
|
export function resolveEbayCountry(farmControlCountryCode) {
|
||||||
|
const ebayCountryCode = toEbayCountryCode(farmControlCountryCode);
|
||||||
|
if (!ebayCountryCode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ebayCountryCode,
|
||||||
|
farmControlCountryCode: farmControlCountryCode.trim().toUpperCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
28
src/integrations/marketplaces/ebay/index.js
Normal file
28
src/integrations/marketplaces/ebay/index.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
export {
|
||||||
|
createAuthorizationUrl,
|
||||||
|
exchangeAuthorizationCode,
|
||||||
|
refreshAuth,
|
||||||
|
ensureAuthenticatedMarketplace,
|
||||||
|
canVerifyWebhookSignature,
|
||||||
|
verifyWebhookSignature,
|
||||||
|
} from './auth.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
syncItems,
|
||||||
|
mapProductToListing,
|
||||||
|
createItem,
|
||||||
|
updateItem,
|
||||||
|
deleteItem,
|
||||||
|
publishOfferById,
|
||||||
|
withdrawOfferById,
|
||||||
|
publishOfferForSku,
|
||||||
|
withdrawOfferForSku,
|
||||||
|
} from './listings.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
syncOrders,
|
||||||
|
mapOrderStatus,
|
||||||
|
mapOrderToSalesOrder,
|
||||||
|
mapBuyerToClient,
|
||||||
|
handleWebhook,
|
||||||
|
} from './orders.js';
|
||||||
744
src/integrations/marketplaces/ebay/listings.js
Normal file
744
src/integrations/marketplaces/ebay/listings.js
Normal file
@ -0,0 +1,744 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { stockLocationModel } from '../../../database/schemas/inventory/stocklocation.schema.js';
|
||||||
|
import {
|
||||||
|
FARMCONTROL_GB_SUBDIVISION_STATE,
|
||||||
|
resolveEbayCountry,
|
||||||
|
} from './countryCodes.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' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (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) {
|
||||||
|
const resolved = resolveStockLocationEbayCountry(listing);
|
||||||
|
return resolved?.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;
|
||||||
|
if (!stockLocationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `fc-${String(stockLocationId)}`.slice(0, 36);
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPostal = Boolean(address.postalCode);
|
||||||
|
const hasCityState = Boolean(address.city && address.stateOrProvince);
|
||||||
|
|
||||||
|
if (!hasPostal && !hasCityState) {
|
||||||
|
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;
|
||||||
|
const addr = stockLocation?.address || {};
|
||||||
|
const address = buildEbayWarehouseAddress(addr, ebayCountryCode, farmControlCountryCode);
|
||||||
|
|
||||||
|
return {
|
||||||
|
location: { address },
|
||||||
|
locationTypes: ['WAREHOUSE'],
|
||||||
|
name: stockLocation?.name || `FarmControl ${ebayCountryCode}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { ebayCountryCode, farmControlCountryCode } = countryResolved;
|
||||||
|
|
||||||
|
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,
|
||||||
|
ebayCountryCode,
|
||||||
|
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) {
|
||||||
|
offer.merchantLocationKey = merchantLocationKey;
|
||||||
|
|
||||||
|
const config = marketplace.config || {};
|
||||||
|
const listingPolicies = {};
|
||||||
|
if (config.fulfillmentPolicyId) {
|
||||||
|
listingPolicies.fulfillmentPolicyId = config.fulfillmentPolicyId;
|
||||||
|
}
|
||||||
|
if (config.paymentPolicyId) {
|
||||||
|
listingPolicies.paymentPolicyId = config.paymentPolicyId;
|
||||||
|
}
|
||||||
|
if (config.returnPolicyId) {
|
||||||
|
listingPolicies.returnPolicyId = config.returnPolicyId;
|
||||||
|
}
|
||||||
|
if (Object.keys(listingPolicies).length > 0) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 = listing.categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return offer;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await makeRequest({
|
||||||
|
marketplace,
|
||||||
|
method: 'PUT',
|
||||||
|
path: `/sell/inventory/v1/offer/${existingOffer.offerId}`,
|
||||||
|
body: { ...existingOffer, ...offerUpdate },
|
||||||
|
});
|
||||||
|
logger.debug('offerUpdate', { ...existingOffer, ...offerUpdate });
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOrReplaceGroup(marketplace, listing, varients) {
|
||||||
|
const groupKey = listing._reference;
|
||||||
|
const variantSKUs = varients.map((v) => v._reference).filter(Boolean);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
title: listing.title || groupKey,
|
||||||
|
variantSKUs,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (listing.description) {
|
||||||
|
body.description = listing.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.imageUrls?.length) {
|
||||||
|
body.imageUrls = listing.imageUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 firstPublishedUrl = '';
|
||||||
|
for (const varient of varients) {
|
||||||
|
try {
|
||||||
|
const publishedUrl = await syncOfferAndMaybePublish(marketplace, listing, varient);
|
||||||
|
if (publishedUrl && !firstPublishedUrl) firstPublishedUrl = publishedUrl;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to create offer for varient ${varient._reference}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstPublishedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validVarients.length === 1) {
|
||||||
|
logger.info(
|
||||||
|
`Syncing standalone eBay inventory item "${validVarients[0]._reference}" for listing "${ref}"`
|
||||||
|
);
|
||||||
|
const url = await syncSingleVarientListing(marketplace, listing, validVarients[0]);
|
||||||
|
return { url };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await syncGroupedListing(marketplace, listing, validVarients);
|
||||||
|
return { url };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 deleteItem(marketplace, listing) {
|
||||||
|
const ref = listing._reference;
|
||||||
|
if (!ref) return;
|
||||||
|
|
||||||
|
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 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* eBay Sell Inventory API — publish offer (creates live listing).
|
||||||
|
* @see https://developer.ebay.com/api-docs/sell/inventory/resources/offer/methods/publishOffer
|
||||||
|
*/
|
||||||
|
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`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* eBay Sell Inventory API — withdraw offer (ends live listing; offer remains for re-publish).
|
||||||
|
* @see https://developer.ebay.com/api-docs/sell/inventory/resources/offer/methods/withdrawOffer
|
||||||
|
*/
|
||||||
|
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 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 offers = await fetchOffers(marketplace, sku);
|
||||||
|
const existingOffer = offers[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.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingOffer.merchantLocationKey !== merchantLocationKey) {
|
||||||
|
await makeRequest({
|
||||||
|
marketplace,
|
||||||
|
method: 'PUT',
|
||||||
|
path: `/sell/inventory/v1/offer/${existingOffer.offerId}`,
|
||||||
|
body: { ...existingOffer, merchantLocationKey },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 offers = await fetchOffers(marketplace, sku);
|
||||||
|
const existingOffer = offers[0];
|
||||||
|
if (!existingOffer?.offerId) {
|
||||||
|
throw new Error(`No eBay offer exists for SKU "${sku}".`);
|
||||||
|
}
|
||||||
|
await withdrawOfferById(marketplace, existingOffer.offerId);
|
||||||
|
return { offerId: existingOffer.offerId };
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LISTING_STATUS_MAP = {
|
||||||
|
ACTIVE: 'active',
|
||||||
|
OUT_OF_STOCK: 'inactive',
|
||||||
|
ENDED: 'inactive',
|
||||||
|
PUBLISHED: 'active',
|
||||||
|
UNPUBLISHED: 'draft',
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVarientEntry(item, offers) {
|
||||||
|
const offer = offers?.[0];
|
||||||
|
const price = offer?.pricingSummary?.price?.value
|
||||||
|
? parseFloat(offer.pricingSummary.price.value)
|
||||||
|
: undefined;
|
||||||
|
const currency = offer?.pricingSummary?.price?.currency || undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
_reference: item.sku,
|
||||||
|
price,
|
||||||
|
currency,
|
||||||
|
state: { type: resolveOfferState(offers || []) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapProductToListing(ebayItem) {
|
||||||
|
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);
|
||||||
|
const url = firstPublishedOffer?.listingId
|
||||||
|
? `https://www.ebay.com/itm/${firstPublishedOffer.listingId}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
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 {
|
||||||
|
_reference: ebayItem._groupKey,
|
||||||
|
title: group.title || ebayItem._groupKey,
|
||||||
|
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;
|
||||||
|
|
||||||
|
let stateType = 'draft';
|
||||||
|
if (offer?.status && LISTING_STATUS_MAP[offer.status]) {
|
||||||
|
stateType = LISTING_STATUS_MAP[offer.status];
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = offer?.listingId ? `https://www.ebay.com/itm/${offer.listingId}` : '';
|
||||||
|
|
||||||
|
const varients = [buildVarientEntry(ebayItem, ebayItem._offers || [])];
|
||||||
|
|
||||||
|
return {
|
||||||
|
_reference: ebayItem.sku,
|
||||||
|
title: ebayItem.product?.title || ebayItem.sku,
|
||||||
|
state: { type: stateType },
|
||||||
|
price,
|
||||||
|
currency,
|
||||||
|
url,
|
||||||
|
varients,
|
||||||
|
};
|
||||||
|
}
|
||||||
156
src/integrations/marketplaces/ebay/orders.js
Normal file
156
src/integrations/marketplaces/ebay/orders.js
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { makeRequest, logger } from './shared.js';
|
||||||
|
|
||||||
|
async function fetchAllOrders(marketplace, { startTime, endTime } = {}) {
|
||||||
|
const orders = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
const filterParts = [];
|
||||||
|
if (startTime) {
|
||||||
|
const isoStart = new Date(startTime * 1000).toISOString();
|
||||||
|
filterParts.push(`creationdate:[${isoStart}..`);
|
||||||
|
}
|
||||||
|
if (endTime) {
|
||||||
|
const isoEnd = new Date(endTime * 1000).toISOString();
|
||||||
|
if (filterParts.length && filterParts[0].startsWith('creationdate:')) {
|
||||||
|
filterParts[0] = filterParts[0] + `${isoEnd}]`;
|
||||||
|
} else {
|
||||||
|
filterParts.push(`creationdate:[..${isoEnd}]`);
|
||||||
|
}
|
||||||
|
} else if (filterParts.length) {
|
||||||
|
filterParts[0] = filterParts[0] + ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
const params = { limit, offset };
|
||||||
|
if (filterParts.length) {
|
||||||
|
params.filter = filterParts.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await makeRequest({
|
||||||
|
marketplace,
|
||||||
|
path: '/sell/fulfillment/v1/order',
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data?.orders?.length) {
|
||||||
|
orders.push(...data.orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data?.orders?.length || orders.length >= (data.total || 0)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
|
} while (true);
|
||||||
|
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncOrders(marketplace, { startTime, endTime } = {}) {
|
||||||
|
logger.info(`Syncing orders from eBay marketplace: ${marketplace.name}`);
|
||||||
|
|
||||||
|
const orders = await fetchAllOrders(marketplace, { startTime, endTime });
|
||||||
|
|
||||||
|
logger.info(`Fetched ${orders.length} order(s) from eBay`);
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER_STATUS_MAP = {
|
||||||
|
NOT_STARTED: 'draft',
|
||||||
|
IN_PROGRESS: 'confirmed',
|
||||||
|
FULFILLED: 'shipped',
|
||||||
|
CANCELLED: 'cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FULFILLMENT_STATUS_MAP = {
|
||||||
|
NOT_STARTED: 'confirmed',
|
||||||
|
IN_PROGRESS: 'shipped',
|
||||||
|
FULFILLED: 'delivered',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapOrderStatus(ebayOrder) {
|
||||||
|
if (ebayOrder.cancelStatus?.cancelState === 'CANCELED') {
|
||||||
|
return 'cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fulfillmentStatus = ebayOrder.fulfillmentStartInstructions?.[0]?.fulfillmentStatus;
|
||||||
|
if (fulfillmentStatus && FULFILLMENT_STATUS_MAP[fulfillmentStatus]) {
|
||||||
|
return FULFILLMENT_STATUS_MAP[fulfillmentStatus];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ORDER_STATUS_MAP[ebayOrder.orderFulfillmentStatus] || 'draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapOrderToSalesOrder(ebayOrder) {
|
||||||
|
const pricingSummary = ebayOrder.pricingSummary || {};
|
||||||
|
const totalAmount = parseFloat(pricingSummary.priceSubtotal?.value || 0);
|
||||||
|
const shippingAmount = parseFloat(pricingSummary.deliveryCost?.value || 0);
|
||||||
|
const totalTax = parseFloat(pricingSummary.tax?.value || 0);
|
||||||
|
const grandTotal = parseFloat(pricingSummary.total?.value || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalId: ebayOrder.orderId,
|
||||||
|
state: { type: mapOrderStatus(ebayOrder) },
|
||||||
|
totalAmount,
|
||||||
|
totalAmountWithTax: totalAmount + totalTax,
|
||||||
|
shippingAmount,
|
||||||
|
shippingAmountWithTax: shippingAmount,
|
||||||
|
grandTotalAmount: grandTotal,
|
||||||
|
totalTaxAmount: totalTax,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapBuyerToClient(ebayOrder) {
|
||||||
|
const buyer = ebayOrder.buyer || {};
|
||||||
|
const address = ebayOrder.fulfillmentStartInstructions?.[0]?.shippingStep?.shipTo || {};
|
||||||
|
|
||||||
|
const fullName = address.fullName || buyer.username || 'Unknown';
|
||||||
|
const contactAddress = address.contactAddress || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: fullName,
|
||||||
|
email: buyer.buyerRegistrationAddress?.email || '',
|
||||||
|
phone: address.primaryPhone?.phoneNumber || '',
|
||||||
|
address: {
|
||||||
|
addressLine1: contactAddress.addressLine1 || '',
|
||||||
|
addressLine2: contactAddress.addressLine2 || '',
|
||||||
|
city: contactAddress.city || '',
|
||||||
|
state: contactAddress.stateOrProvince || '',
|
||||||
|
postcode: contactAddress.postalCode || '',
|
||||||
|
country: contactAddress.countryCode || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWebhook(marketplace, event) {
|
||||||
|
const { topic, data } = event;
|
||||||
|
|
||||||
|
logger.info(`eBay webhook received: ${topic} for marketplace ${marketplace.name}`);
|
||||||
|
|
||||||
|
if (topic?.startsWith('marketplace.account_deletion')) {
|
||||||
|
return { action: 'accountDeletion', userId: data?.userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (topic) {
|
||||||
|
case 'item.sold':
|
||||||
|
return { action: 'orderCreate', orderId: data?.orderId };
|
||||||
|
|
||||||
|
case 'item.created':
|
||||||
|
case 'item.updated':
|
||||||
|
return { action: 'productUpdate', itemId: data?.itemId };
|
||||||
|
|
||||||
|
case 'item.ended':
|
||||||
|
return { action: 'productEnded', itemId: data?.itemId };
|
||||||
|
|
||||||
|
case 'order.cancelled':
|
||||||
|
return { action: 'orderCancel', orderId: data?.orderId };
|
||||||
|
|
||||||
|
case 'order.fulfillment':
|
||||||
|
return { action: 'orderUpdate', orderId: data?.orderId };
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.debug(`Unhandled eBay webhook topic: ${topic}`);
|
||||||
|
return { action: 'unknown', topic };
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/integrations/marketplaces/ebay/shared.js
Normal file
172
src/integrations/marketplaces/ebay/shared.js
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import config from '../../../config.js';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('eBay');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
const SANDBOX_API_URL = 'https://api.sandbox.ebay.com';
|
||||||
|
const PRODUCTION_API_URL = 'https://api.ebay.com';
|
||||||
|
const SANDBOX_AUTH_URL = 'https://auth.sandbox.ebay.com';
|
||||||
|
const PRODUCTION_AUTH_URL = 'https://auth.ebay.com';
|
||||||
|
const TOKEN_PATH = '/identity/v1/oauth2/token';
|
||||||
|
const DEFAULT_SCOPES = [
|
||||||
|
'https://api.ebay.com/oauth/api_scope',
|
||||||
|
'https://api.ebay.com/oauth/api_scope/sell.inventory',
|
||||||
|
'https://api.ebay.com/oauth/api_scope/sell.fulfillment',
|
||||||
|
'https://api.ebay.com/oauth/api_scope/sell.account',
|
||||||
|
];
|
||||||
|
const MARKETPLACE_LANGUAGE_MAP = {
|
||||||
|
EBAY_US: 'en-US',
|
||||||
|
EBAY_GB: 'en-GB',
|
||||||
|
EBAY_AU: 'en-AU',
|
||||||
|
EBAY_CA: 'en-CA',
|
||||||
|
EBAY_DE: 'de-DE',
|
||||||
|
EBAY_FR: 'fr-FR',
|
||||||
|
EBAY_ES: 'es-ES',
|
||||||
|
EBAY_IT: 'it-IT',
|
||||||
|
EBAY_NL: 'nl-NL',
|
||||||
|
EBAY_BE: 'nl-BE',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getApiBaseUrl(marketplace) {
|
||||||
|
return marketplace.config.sandbox ? SANDBOX_API_URL : PRODUCTION_API_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthorizeBaseUrl(marketplace) {
|
||||||
|
return marketplace.config.sandbox ? SANDBOX_AUTH_URL : PRODUCTION_AUTH_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScopes(marketplace) {
|
||||||
|
if (Array.isArray(marketplace.config.scopes) && marketplace.config.scopes.length) {
|
||||||
|
return marketplace.config.scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof marketplace.config.scopes === 'string' && marketplace.config.scopes.trim()) {
|
||||||
|
return marketplace.config.scopes.trim().split(/\s+/);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_SCOPES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScopesString(marketplace) {
|
||||||
|
return getScopes(marketplace).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidLanguageTag(value) {
|
||||||
|
return /^[a-z]{2,3}(?:-[A-Z]{2})?$/.test(value || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAcceptLanguage(marketplace) {
|
||||||
|
const configured =
|
||||||
|
marketplace.config?.acceptLanguage ||
|
||||||
|
marketplace.config?.locale ||
|
||||||
|
MARKETPLACE_LANGUAGE_MAP[marketplace.config?.marketplaceId];
|
||||||
|
|
||||||
|
if (typeof configured === 'string') {
|
||||||
|
const normalized = configured.replace('_', '-').trim();
|
||||||
|
if (isValidLanguageTag(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en-GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTokenExpiryDate(expiresInSeconds) {
|
||||||
|
return new Date(Date.now() + Math.max(Number(expiresInSeconds || 0) - 60, 0) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAccessTokenExpired(marketplace) {
|
||||||
|
const { accessToken, accessTokenExpiresAt } = marketplace.config || {};
|
||||||
|
if (!accessToken || !accessTokenExpiresAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(accessTokenExpiresAt).getTime() <= Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequiredAuthConfig(marketplace) {
|
||||||
|
const { clientId, clientSecret } = marketplace.config || {};
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
throw new Error('eBay marketplace is missing required config (clientId, clientSecret)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clientId, clientSecret };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBasicAuthHeader(marketplace) {
|
||||||
|
const { clientId, clientSecret } = getRequiredAuthConfig(marketplace);
|
||||||
|
return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function makeRequest({
|
||||||
|
marketplace,
|
||||||
|
method = 'GET',
|
||||||
|
path,
|
||||||
|
params = {},
|
||||||
|
body = null,
|
||||||
|
acceptableStatuses = [],
|
||||||
|
} = {}) {
|
||||||
|
const { accessToken } = marketplace.config || {};
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error(
|
||||||
|
'eBay marketplace is not authenticated. Complete marketplace authorization first.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = Object.entries(params)
|
||||||
|
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||||
|
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
const url = queryString
|
||||||
|
? `${getApiBaseUrl(marketplace)}${path}?${queryString}`
|
||||||
|
: `${getApiBaseUrl(marketplace)}${path}`;
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Accept-Language': getAcceptLanguage(marketplace),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (marketplace.config.marketplaceId) {
|
||||||
|
headers['X-EBAY-C-MARKETPLACE-ID'] = marketplace.config.marketplaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body && method !== 'GET') {
|
||||||
|
fetchOptions.headers['Content-Type'] = 'application/json';
|
||||||
|
fetchOptions.headers['Content-Language'] = getAcceptLanguage(marketplace);
|
||||||
|
fetchOptions.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`eBay API ${method} ${path}`);
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
console.log('DATA: ' + JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
if (!response.ok && acceptableStatuses.includes(response.status)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = data.errors?.[0]?.message || data.error_description || response.statusText;
|
||||||
|
logger.error(`eBay API error: ${message}`, { status: response.status, path });
|
||||||
|
throw new Error(`eBay API error (${response.status}): ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { logger };
|
||||||
662
src/integrations/marketplaces/tiktokShop.js
Normal file
662
src/integrations/marketplaces/tiktokShop.js
Normal file
@ -0,0 +1,662 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import config from '../../config.js';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('TikTok Shop');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
const BASE_URL = 'https://open-api.tiktokglobalshop.com';
|
||||||
|
const AUTH_BASE_URL = 'https://auth.tiktok-shops.com';
|
||||||
|
const API_VERSION = '202309';
|
||||||
|
const AUTHORIZED_SHOPS_PATH = `/authorization/${API_VERSION}/shops`;
|
||||||
|
|
||||||
|
function getTokenExpiryDate(unixTimestampSeconds) {
|
||||||
|
return new Date(Number(unixTimestampSeconds || 0) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAccessTokenExpired(marketplace) {
|
||||||
|
const { accessToken, accessTokenExpiresAt } = marketplace.config || {};
|
||||||
|
if (!accessToken || !accessTokenExpiresAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(accessTokenExpiresAt).getTime() <= Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIncludeShopCipher(path, method = 'GET') {
|
||||||
|
if (/^\/product\/(\d{6})\/(compliance|global_products|files\/upload|images\/upload)/.test(path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && /^\/product\/(\d{6})\/brands/.test(path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/(authorization|seller)\/(\d{6})\//.test(path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSignature({ path, method, params, body, appSecret }) {
|
||||||
|
const paramsToBeSigned = { ...params };
|
||||||
|
delete paramsToBeSigned.sign;
|
||||||
|
delete paramsToBeSigned.access_token;
|
||||||
|
delete paramsToBeSigned['x-tts-access-token'];
|
||||||
|
|
||||||
|
const sortedKeys = Object.keys(paramsToBeSigned).sort();
|
||||||
|
let payload = path;
|
||||||
|
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
const value = paramsToBeSigned[key];
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
payload += `${key}${value}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method !== 'GET' && body && typeof body === 'object') {
|
||||||
|
payload += JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = `${appSecret}${payload}${appSecret}`;
|
||||||
|
return crypto.createHmac('sha256', appSecret).update(payload).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestAuth(path, params) {
|
||||||
|
const url = new URL(path, AUTH_BASE_URL);
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), { method: 'GET' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || data.code !== 0) {
|
||||||
|
const message = data.message || response.statusText;
|
||||||
|
logger.error(`TikTok Shop auth error: ${message}`, { path, code: data.code });
|
||||||
|
throw new Error(`TikTok Shop auth error: ${message} (code: ${data.code ?? response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeRequest({ marketplace, method = 'GET', path, params = {}, body = null }) {
|
||||||
|
const { appKey, appSecret, accessToken, shopCipher } = marketplace.config || {};
|
||||||
|
|
||||||
|
if (!appKey || !appSecret || !accessToken) {
|
||||||
|
throw new Error(
|
||||||
|
'TikTok Shop marketplace is missing required config (appKey, appSecret, accessToken)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams = {
|
||||||
|
...params,
|
||||||
|
app_key: appKey,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shopCipher && shouldIncludeShopCipher(path, method)) {
|
||||||
|
queryParams.shop_cipher = shopCipher;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = generateSignature({
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
params: queryParams,
|
||||||
|
body,
|
||||||
|
appSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
queryParams.sign = sign;
|
||||||
|
|
||||||
|
const queryString = Object.entries(queryParams)
|
||||||
|
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||||
|
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
const url = `${BASE_URL}${path}?${queryString}`;
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-tts-access-token': accessToken,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body && method !== 'GET') {
|
||||||
|
fetchOptions.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`TikTok Shop API ${method} ${path}`);
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.code !== 0) {
|
||||||
|
logger.error(`TikTok Shop API error: ${data.message}`, { code: data.code, path });
|
||||||
|
throw new Error(`TikTok Shop API error: ${data.message} (code: ${data.code})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAuthorizedShops(marketplace, accessToken) {
|
||||||
|
const authMarketplace = {
|
||||||
|
...marketplace,
|
||||||
|
config: {
|
||||||
|
...(marketplace.config || {}),
|
||||||
|
accessToken,
|
||||||
|
shopCipher: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await makeRequest({
|
||||||
|
marketplace: authMarketplace,
|
||||||
|
method: 'GET',
|
||||||
|
path: AUTHORIZED_SHOPS_PATH,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(data?.shops)) {
|
||||||
|
return data.shops;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data?.shop_list)) {
|
||||||
|
return data.shop_list;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAuthorizedShop(marketplace, shops) {
|
||||||
|
if (!shops.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingShopCipher = marketplace.config?.shopCipher;
|
||||||
|
const existingShopId = marketplace.config?.shopId;
|
||||||
|
const existingShopCode = marketplace.config?.shopCode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
shops.find((shop) => shop.cipher === existingShopCipher) ||
|
||||||
|
shops.find((shop) => shop.id === existingShopId) ||
|
||||||
|
shops.find((shop) => shop.code === existingShopCode) ||
|
||||||
|
shops[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredAuthConfig(marketplace) {
|
||||||
|
const { appKey, appSecret } = marketplace.config || {};
|
||||||
|
if (!appKey || !appSecret) {
|
||||||
|
throw new Error('TikTok Shop marketplace is missing required config (appKey, appSecret)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { appKey, appSecret };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAuthConfigUpdates(marketplace, tokenData, shop) {
|
||||||
|
return {
|
||||||
|
accessToken: tokenData.access_token,
|
||||||
|
accessTokenExpiresAt: getTokenExpiryDate(tokenData.access_token_expire_in).toISOString(),
|
||||||
|
refreshToken: tokenData.refresh_token || marketplace.config.refreshToken,
|
||||||
|
refreshTokenExpiresAt: tokenData.refresh_token_expire_in
|
||||||
|
? getTokenExpiryDate(tokenData.refresh_token_expire_in).toISOString()
|
||||||
|
: marketplace.config.refreshTokenExpiresAt,
|
||||||
|
openId: tokenData.open_id || marketplace.config.openId,
|
||||||
|
grantedScopes: tokenData.granted_scopes || marketplace.config.grantedScopes || [],
|
||||||
|
sellerName: tokenData.seller_name || shop?.name || marketplace.config.sellerName,
|
||||||
|
sellerBaseRegion:
|
||||||
|
tokenData.seller_base_region || shop?.region || marketplace.config.sellerBaseRegion,
|
||||||
|
shopCipher: shop?.cipher || marketplace.config.shopCipher,
|
||||||
|
shopId: shop?.id || marketplace.config.shopId,
|
||||||
|
shopCode: shop?.code || marketplace.config.shopCode,
|
||||||
|
shopName: shop?.name || marketplace.config.shopName,
|
||||||
|
shopRegion: shop?.region || marketplace.config.shopRegion,
|
||||||
|
lastTokenRefreshAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthorizationUrl(marketplace, { state } = {}) {
|
||||||
|
const { appKey } = getRequiredAuthConfig(marketplace);
|
||||||
|
|
||||||
|
const url = new URL('/oauth/authorize', AUTH_BASE_URL);
|
||||||
|
url.searchParams.set('app_key', appKey);
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marketplace.config.redirectUri) {
|
||||||
|
url.searchParams.set('redirect_uri', marketplace.config.redirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeAuthorizationCode(marketplace, { code }) {
|
||||||
|
const { appKey, appSecret } = getRequiredAuthConfig(marketplace);
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('Missing TikTok Shop authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await requestAuth('/api/v2/token/get', {
|
||||||
|
app_key: appKey,
|
||||||
|
app_secret: appSecret,
|
||||||
|
auth_code: code,
|
||||||
|
grant_type: 'authorized_code',
|
||||||
|
});
|
||||||
|
|
||||||
|
const shops = await fetchAuthorizedShops(marketplace, tokenData.access_token);
|
||||||
|
const selectedShop = pickAuthorizedShop(marketplace, shops);
|
||||||
|
|
||||||
|
return {
|
||||||
|
configUpdates: mapAuthConfigUpdates(marketplace, tokenData, selectedShop),
|
||||||
|
marketplaceUpdates: {
|
||||||
|
connected: true,
|
||||||
|
connectedAt: new Date(),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
shopCount: shops.length,
|
||||||
|
selectedShop,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAuth(marketplace) {
|
||||||
|
const { appKey, appSecret } = getRequiredAuthConfig(marketplace);
|
||||||
|
const { refreshToken } = marketplace.config || {};
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('TikTok Shop marketplace is missing required config (refreshToken)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await requestAuth('/api/v2/token/refresh', {
|
||||||
|
app_key: appKey,
|
||||||
|
app_secret: appSecret,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
});
|
||||||
|
|
||||||
|
let selectedShop = null;
|
||||||
|
if (!marketplace.config?.shopCipher) {
|
||||||
|
const shops = await fetchAuthorizedShops(marketplace, tokenData.access_token);
|
||||||
|
selectedShop = pickAuthorizedShop(marketplace, shops);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
configUpdates: mapAuthConfigUpdates(marketplace, tokenData, selectedShop),
|
||||||
|
data: {
|
||||||
|
selectedShop,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureAuthenticatedMarketplace(marketplace) {
|
||||||
|
if (!isAccessTokenExpired(marketplace)) {
|
||||||
|
return { marketplace };
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = await refreshAuth(marketplace);
|
||||||
|
return {
|
||||||
|
marketplace: {
|
||||||
|
...marketplace,
|
||||||
|
config: {
|
||||||
|
...(marketplace.config || {}),
|
||||||
|
...authResult.configUpdates,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configUpdates: authResult.configUpdates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllProducts(marketplace) {
|
||||||
|
const products = [];
|
||||||
|
let pageToken = '';
|
||||||
|
|
||||||
|
do {
|
||||||
|
const body = {
|
||||||
|
page_size: 100,
|
||||||
|
};
|
||||||
|
if (pageToken) {
|
||||||
|
body.page_token = pageToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await makeRequest({
|
||||||
|
marketplace,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/product/${API_VERSION}/products/search`,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.products?.length) {
|
||||||
|
products.push(...data.products);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageToken = data.next_page_token || '';
|
||||||
|
} while (pageToken);
|
||||||
|
|
||||||
|
return products;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProductDetail(marketplace, productId) {
|
||||||
|
return makeRequest({
|
||||||
|
marketplace,
|
||||||
|
method: 'GET',
|
||||||
|
path: `/product/${API_VERSION}/products/${productId}`,
|
||||||
|
params: { return_under_review_version: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllOrders(marketplace, { startTime, endTime } = {}) {
|
||||||
|
const orders = [];
|
||||||
|
let pageToken = '';
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
do {
|
||||||
|
const body = {
|
||||||
|
page_size: 50,
|
||||||
|
sort_field: 'CREATE_TIME',
|
||||||
|
sort_order: 'DESC',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startTime || endTime) {
|
||||||
|
body.create_time_ge = startTime || now - 86400 * 30;
|
||||||
|
body.create_time_lt = endTime || now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageToken) {
|
||||||
|
body.page_token = pageToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await makeRequest({
|
||||||
|
marketplace,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/order/${API_VERSION}/orders/search`,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.orders?.length) {
|
||||||
|
orders.push(...data.orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageToken = data.next_page_token || '';
|
||||||
|
} while (pageToken);
|
||||||
|
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOrderDetail(marketplace, orderId) {
|
||||||
|
return makeRequest({
|
||||||
|
marketplace,
|
||||||
|
method: 'GET',
|
||||||
|
path: `/order/${API_VERSION}/orders/${orderId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapListingToProduct(listing) {
|
||||||
|
const product = {
|
||||||
|
title: listing.title || '',
|
||||||
|
is_cod_allowed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (listing.description) {
|
||||||
|
product.description = listing.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.categoryId) {
|
||||||
|
product.category_id = listing.categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.imageUrls?.length) {
|
||||||
|
product.main_images = listing.imageUrls.map((url) => ({ uri: url }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sku = {
|
||||||
|
inventory: [{ quantity: listing.inventory ?? 0 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (listing.price != null) {
|
||||||
|
sku.price = {
|
||||||
|
amount: String(listing.price),
|
||||||
|
currency: listing.currency || 'GBP',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.externalId) {
|
||||||
|
sku.seller_sku = listing.externalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
product.skus = [sku];
|
||||||
|
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createItem(marketplace, listing) {
|
||||||
|
logger.info(`Creating TikTok Shop product: ${listing.title}`);
|
||||||
|
|
||||||
|
const productBody = mapListingToProduct(listing);
|
||||||
|
const result = await makeRequest({
|
||||||
|
marketplace,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/product/${API_VERSION}/products`,
|
||||||
|
body: productBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const productId = result?.product_id || result?.id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalId: productId,
|
||||||
|
url: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateItem(marketplace, listing) {
|
||||||
|
const productId = listing.externalId;
|
||||||
|
if (!productId) {
|
||||||
|
throw new Error('Listing must have an externalId to update on TikTok Shop');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Updating TikTok Shop product: ${productId}`);
|
||||||
|
|
||||||
|
const productBody = mapListingToProduct(listing);
|
||||||
|
await makeRequest({
|
||||||
|
marketplace,
|
||||||
|
method: 'PUT',
|
||||||
|
path: `/product/${API_VERSION}/products/${productId}`,
|
||||||
|
body: productBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { externalId: productId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncItems(marketplace) {
|
||||||
|
logger.info(`Syncing products from TikTok Shop marketplace: ${marketplace.name}`);
|
||||||
|
|
||||||
|
const products = await fetchAllProducts(marketplace);
|
||||||
|
const detailed = [];
|
||||||
|
|
||||||
|
for (const product of products) {
|
||||||
|
try {
|
||||||
|
const detail = await fetchProductDetail(marketplace, product.id);
|
||||||
|
detailed.push(detail);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to fetch detail for product ${product.id}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Fetched ${detailed.length} product(s) from TikTok Shop`);
|
||||||
|
return detailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncOrders(marketplace, { startTime, endTime } = {}) {
|
||||||
|
logger.info(`Syncing orders from TikTok Shop marketplace: ${marketplace.name}`);
|
||||||
|
|
||||||
|
const orders = await fetchAllOrders(marketplace, { startTime, endTime });
|
||||||
|
const detailed = [];
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
try {
|
||||||
|
const detail = await fetchOrderDetail(marketplace, order.id);
|
||||||
|
detailed.push(detail);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to fetch detail for order ${order.id}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Fetched ${detailed.length} order(s) from TikTok Shop`);
|
||||||
|
return detailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER_STATUS_MAP = {
|
||||||
|
UNPAID: 'draft',
|
||||||
|
ON_HOLD: 'draft',
|
||||||
|
AWAITING_SHIPMENT: 'confirmed',
|
||||||
|
PARTIALLY_SHIPPING: 'partiallyShipped',
|
||||||
|
AWAITING_COLLECTION: 'shipped',
|
||||||
|
IN_TRANSIT: 'shipped',
|
||||||
|
DELIVERED: 'delivered',
|
||||||
|
COMPLETED: 'completed',
|
||||||
|
CANCELLED: 'cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapOrderStatus(tiktokStatus) {
|
||||||
|
return ORDER_STATUS_MAP[tiktokStatus] || 'draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapOrderToSalesOrder(tiktokOrder) {
|
||||||
|
const paymentInfo = tiktokOrder.payment || {};
|
||||||
|
const totalAmount = parseFloat(paymentInfo.product_total_price || 0);
|
||||||
|
const shippingAmount = parseFloat(paymentInfo.shipping_fee || 0);
|
||||||
|
const totalTax = parseFloat(paymentInfo.tax || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalId: tiktokOrder.id,
|
||||||
|
state: { type: mapOrderStatus(tiktokOrder.status) },
|
||||||
|
totalAmount,
|
||||||
|
totalAmountWithTax: totalAmount + totalTax,
|
||||||
|
shippingAmount,
|
||||||
|
shippingAmountWithTax: shippingAmount,
|
||||||
|
grandTotalAmount: parseFloat(paymentInfo.total_amount || 0),
|
||||||
|
totalTaxAmount: totalTax,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRODUCT_STATUS_MAP = {
|
||||||
|
DRAFT: 'draft',
|
||||||
|
PENDING: 'draft',
|
||||||
|
LIVE: 'active',
|
||||||
|
SELLER_DEACTIVATED: 'inactive',
|
||||||
|
PLATFORM_DEACTIVATED: 'suspended',
|
||||||
|
FROZEN: 'suspended',
|
||||||
|
DELETED: 'deleted',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapProductToListing(tiktokProduct) {
|
||||||
|
const firstSku = tiktokProduct.skus?.[0];
|
||||||
|
const price = firstSku?.price?.sale_price
|
||||||
|
? parseFloat(firstSku.price.sale_price)
|
||||||
|
: firstSku?.price?.original_price
|
||||||
|
? parseFloat(firstSku.price.original_price)
|
||||||
|
: undefined;
|
||||||
|
const currency = firstSku?.price?.currency || undefined;
|
||||||
|
const totalInventory = (tiktokProduct.skus || []).reduce(
|
||||||
|
(sum, sku) => sum + (sku.inventory?.[0]?.quantity || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const varients = (tiktokProduct.skus || []).map((sku) => {
|
||||||
|
const skuPrice = sku.price?.sale_price
|
||||||
|
? parseFloat(sku.price.sale_price)
|
||||||
|
: sku.price?.original_price
|
||||||
|
? parseFloat(sku.price.original_price)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
_reference: sku.id || sku.seller_sku || '',
|
||||||
|
price: skuPrice,
|
||||||
|
currency: sku.price?.currency || currency,
|
||||||
|
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
_reference: tiktokProduct.id,
|
||||||
|
title: tiktokProduct.title || '',
|
||||||
|
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
|
||||||
|
price,
|
||||||
|
currency,
|
||||||
|
inventory: totalInventory,
|
||||||
|
url: tiktokProduct.url || '',
|
||||||
|
varients,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapBuyerToClient(tiktokOrder) {
|
||||||
|
const buyer = tiktokOrder.buyer_info || {};
|
||||||
|
const address = tiktokOrder.recipient_address || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: buyer.name || address.name || 'Unknown',
|
||||||
|
email: buyer.email || '',
|
||||||
|
phone: buyer.phone_number || address.phone_number || '',
|
||||||
|
address: {
|
||||||
|
addressLine1: address.address_line1 || '',
|
||||||
|
addressLine2: address.address_line2 || '',
|
||||||
|
city: address.city || '',
|
||||||
|
state: address.state || '',
|
||||||
|
postcode: address.zipcode || '',
|
||||||
|
country: address.region_code || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWebhook(marketplace, event) {
|
||||||
|
const { type, data } = event;
|
||||||
|
|
||||||
|
logger.info(`TikTok Shop webhook received: ${type} for marketplace ${marketplace.name}`);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'ORDER_STATUS_CHANGE':
|
||||||
|
return { action: 'orderUpdate', orderId: data.order_id, status: data.order_status };
|
||||||
|
|
||||||
|
case 'ORDER_CREATION':
|
||||||
|
return { action: 'orderCreate', orderId: data.order_id };
|
||||||
|
|
||||||
|
case 'PRODUCT_STATUS_CHANGE':
|
||||||
|
return { action: 'productUpdate', productId: data.product_id, status: data.product_status };
|
||||||
|
|
||||||
|
case 'CANCELLATION_REQUEST':
|
||||||
|
return { action: 'orderCancel', orderId: data.order_id };
|
||||||
|
|
||||||
|
case 'RETURN_REQUEST':
|
||||||
|
return { action: 'returnRequest', orderId: data.order_id, returnId: data.return_id };
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.debug(`Unhandled TikTok Shop webhook type: ${type}`);
|
||||||
|
return { action: 'unknown', type };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canVerifyWebhookSignature(marketplace) {
|
||||||
|
return !!marketplace.config?.appSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyWebhookSignature(marketplace, body, signature) {
|
||||||
|
const appSecret = marketplace.config?.appSecret;
|
||||||
|
if (!appSecret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const computed = crypto.createHmac('sha256', appSecret).update(body).digest('hex');
|
||||||
|
try {
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
591
src/integrations/marketplaceworker.js
Normal file
591
src/integrations/marketplaceworker.js
Normal file
@ -0,0 +1,591 @@
|
|||||||
|
import config from '../config.js';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
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 { editObject, newObject } from '../database/database.js';
|
||||||
|
import * as tiktokShop from './marketplaces/tiktokShop.js';
|
||||||
|
import * as ebay from './marketplaces/ebay/index.js';
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('Marketplace Worker');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
const providers = {
|
||||||
|
tiktokShop,
|
||||||
|
ebay,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getProvider(marketplace) {
|
||||||
|
const provider = providers[marketplace.provider];
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(`No integration available for provider: ${marketplace.provider}`);
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasIntegration(provider) {
|
||||||
|
return !!providers[provider];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishMarketplaceOfferForSku(marketplace, user, sku, listing) {
|
||||||
|
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||||
|
const provider = getProvider(authenticatedMarketplace);
|
||||||
|
if (typeof provider.publishOfferForSku !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
`Marketplace provider "${marketplace.provider}" does not support publishing offers`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return provider.publishOfferForSku(authenticatedMarketplace, sku, listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withdrawMarketplaceOfferForSku(marketplace, user, sku) {
|
||||||
|
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||||
|
const provider = getProvider(authenticatedMarketplace);
|
||||||
|
if (typeof provider.withdrawOfferForSku !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
`Marketplace provider "${marketplace.provider}" does not support withdrawing offers`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return provider.withdrawOfferForSku(authenticatedMarketplace, sku);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistMarketplaceUpdate(marketplace, configUpdates, marketplaceUpdates, user) {
|
||||||
|
const updateData = { updatedAt: new Date() };
|
||||||
|
|
||||||
|
if (configUpdates && Object.keys(configUpdates).length > 0) {
|
||||||
|
updateData.config = {
|
||||||
|
...(marketplace.config || {}),
|
||||||
|
...configUpdates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marketplaceUpdates && Object.keys(marketplaceUpdates).length > 0) {
|
||||||
|
Object.assign(updateData, marketplaceUpdates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length <= 1) {
|
||||||
|
return marketplace;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editObject({
|
||||||
|
model: marketplaceModel,
|
||||||
|
id: marketplace._id,
|
||||||
|
updateData,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureMarketplaceAuth(marketplace, user) {
|
||||||
|
const provider = getProvider(marketplace);
|
||||||
|
|
||||||
|
if (!provider.ensureAuthenticatedMarketplace) {
|
||||||
|
return marketplace;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = await provider.ensureAuthenticatedMarketplace(marketplace);
|
||||||
|
if (!authResult?.configUpdates) {
|
||||||
|
return authResult?.marketplace || marketplace;
|
||||||
|
}
|
||||||
|
|
||||||
|
return persistMarketplaceUpdate(
|
||||||
|
marketplace,
|
||||||
|
authResult.configUpdates,
|
||||||
|
authResult.marketplaceUpdates,
|
||||||
|
user
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAuthorize(marketplace) {
|
||||||
|
const provider = getProvider(marketplace);
|
||||||
|
return (
|
||||||
|
typeof provider.createAuthorizationUrl === 'function' &&
|
||||||
|
typeof provider.exchangeAuthorizationCode === 'function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthorizationUrl(marketplace, { state } = {}) {
|
||||||
|
const provider = getProvider(marketplace);
|
||||||
|
if (!provider.createAuthorizationUrl) {
|
||||||
|
throw new Error(`Provider ${marketplace.provider} does not support marketplace authorization`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.createAuthorizationUrl(marketplace, { state });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeAuthorizationCode(marketplace, user, { code, state } = {}) {
|
||||||
|
const provider = getProvider(marketplace);
|
||||||
|
if (!provider.exchangeAuthorizationCode) {
|
||||||
|
throw new Error(`Provider ${marketplace.provider} does not support marketplace authorization`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = await provider.exchangeAuthorizationCode(marketplace, { code, state });
|
||||||
|
const updatedMarketplace = await persistMarketplaceUpdate(
|
||||||
|
marketplace,
|
||||||
|
authResult?.configUpdates || {},
|
||||||
|
authResult?.marketplaceUpdates,
|
||||||
|
user
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketplace: updatedMarketplace,
|
||||||
|
...(authResult?.data ? { data: authResult.data } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshMarketplaceAuth(marketplace, user) {
|
||||||
|
const provider = getProvider(marketplace);
|
||||||
|
if (!provider.refreshAuth) {
|
||||||
|
throw new Error(`Provider ${marketplace.provider} does not support token refresh`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = await provider.refreshAuth(marketplace);
|
||||||
|
const updatedMarketplace = await persistMarketplaceUpdate(
|
||||||
|
marketplace,
|
||||||
|
authResult?.configUpdates || {},
|
||||||
|
authResult?.marketplaceUpdates,
|
||||||
|
user
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketplace: updatedMarketplace,
|
||||||
|
...(authResult?.data ? { data: authResult.data } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canVerifyWebhookSignature(marketplace) {
|
||||||
|
const provider = getProvider(marketplace);
|
||||||
|
if (typeof provider.verifyWebhookSignature !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof provider.canVerifyWebhookSignature === 'function') {
|
||||||
|
return provider.canVerifyWebhookSignature(marketplace);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyWebhookSignature(marketplace, rawBody, signature) {
|
||||||
|
const provider = getProvider(marketplace);
|
||||||
|
if (!provider.verifyWebhookSignature) {
|
||||||
|
logger.warn(`Provider ${marketplace.provider} does not support webhook signature verification`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.verifyWebhookSignature(marketplace, rawBody, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWebhook(marketplace, event) {
|
||||||
|
const provider = getProvider(marketplace);
|
||||||
|
return provider.handleWebhook(marketplace, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setListingState(listingId, stateType, user, message) {
|
||||||
|
const state = { type: stateType };
|
||||||
|
if (message) state.message = message;
|
||||||
|
return editObject({
|
||||||
|
model: listingModel,
|
||||||
|
id: listingId,
|
||||||
|
updateData: { state },
|
||||||
|
user,
|
||||||
|
recalculate: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setListingVarientState(varientId, stateType, user, message) {
|
||||||
|
const state = { type: stateType };
|
||||||
|
if (message) state.message = message;
|
||||||
|
return editObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id: varientId,
|
||||||
|
updateData: { state },
|
||||||
|
user,
|
||||||
|
recalculate: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMarketplaceState(marketplaceId, stateType, user, message) {
|
||||||
|
const state = { type: stateType };
|
||||||
|
if (message) state.message = message;
|
||||||
|
return editObject({
|
||||||
|
model: marketplaceModel,
|
||||||
|
id: marketplaceId,
|
||||||
|
updateData: { state },
|
||||||
|
user,
|
||||||
|
recalculate: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recalculateMarketplaceState(marketplace, user) {
|
||||||
|
await marketplaceModel.recalculate(marketplace, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFullListing(listingId) {
|
||||||
|
return listingModel
|
||||||
|
.findById(listingId)
|
||||||
|
.populate(['product', 'vendor', 'stockLocation', 'marketplace'])
|
||||||
|
.lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchListingVarients(listingId) {
|
||||||
|
return listingVarientModel.find({ listing: listingId }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createListing(marketplace, user, listingData) {
|
||||||
|
const provider = getProvider(marketplace);
|
||||||
|
if (!provider.createItem) {
|
||||||
|
logger.debug(`Provider ${marketplace.provider} does not support createItem — skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listingData._id) {
|
||||||
|
setListingState(listingData._id, 'syncing', user).catch((err) =>
|
||||||
|
logger.warn(`Failed to set listing syncing state: ${err.message}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const work = async () => {
|
||||||
|
try {
|
||||||
|
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||||
|
const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData;
|
||||||
|
if (!fullListing) throw new Error('Listing not found');
|
||||||
|
|
||||||
|
const varients = listingData._id ? await fetchListingVarients(listingData._id) : [];
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Creating listing on marketplace "${marketplace.name}" (${marketplace.provider})`
|
||||||
|
);
|
||||||
|
const result = await provider.createItem(authenticatedMarketplace, fullListing, varients);
|
||||||
|
|
||||||
|
if (listingData._id) {
|
||||||
|
const updateData = { lastSyncedAt: new Date(), state: { type: 'active' } };
|
||||||
|
if (result?.url) updateData.url = result.url;
|
||||||
|
await editObject({ model: listingModel, id: listingData._id, updateData, user });
|
||||||
|
|
||||||
|
for (const varient of varients) {
|
||||||
|
await editObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id: varient._id,
|
||||||
|
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
|
||||||
|
user,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Background createListing complete for marketplace "${marketplace.name}"`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`Background createListing failed for marketplace "${marketplace.name}": ${err.message}`
|
||||||
|
);
|
||||||
|
if (listingData._id) {
|
||||||
|
await setListingState(listingData._id, 'draft', user, err.message).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
work();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateListing(marketplace, user, listingData) {
|
||||||
|
const provider = getProvider(marketplace);
|
||||||
|
if (!provider.updateItem) {
|
||||||
|
logger.debug(`Provider ${marketplace.provider} does not support updateItem — skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listingData._id) {
|
||||||
|
setListingState(listingData._id, 'syncing', user).catch((err) =>
|
||||||
|
logger.warn(`Failed to set listing syncing state: ${err.message}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const work = async () => {
|
||||||
|
try {
|
||||||
|
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||||
|
const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData;
|
||||||
|
if (!fullListing) throw new Error('Listing not found');
|
||||||
|
|
||||||
|
const varients = listingData._id ? await fetchListingVarients(listingData._id) : [];
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Updating listing on marketplace "${marketplace.name}" (${marketplace.provider})`
|
||||||
|
);
|
||||||
|
await provider.updateItem(authenticatedMarketplace, fullListing, varients);
|
||||||
|
|
||||||
|
if (listingData._id) {
|
||||||
|
await editObject({
|
||||||
|
model: listingModel,
|
||||||
|
id: listingData._id,
|
||||||
|
updateData: { state: { type: 'active' }, lastSyncedAt: new Date() },
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const varient of varients) {
|
||||||
|
await editObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id: varient._id,
|
||||||
|
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
|
||||||
|
user,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Background updateListing complete for marketplace "${marketplace.name}"`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`Background updateListing failed for marketplace "${marketplace.name}": ${err.message}`
|
||||||
|
);
|
||||||
|
if (listingData._id) {
|
||||||
|
await setListingState(listingData._id, 'active', user, err.message).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
work();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteListing(marketplace, user, listingData) {
|
||||||
|
const provider = getProvider(marketplace);
|
||||||
|
if (!provider.deleteItem) {
|
||||||
|
logger.debug(`Provider ${marketplace.provider} does not support deleteItem — skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const work = async () => {
|
||||||
|
try {
|
||||||
|
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||||
|
logger.info(
|
||||||
|
`Deleting listing from marketplace "${marketplace.name}" (${marketplace.provider})`
|
||||||
|
);
|
||||||
|
await provider.deleteItem(authenticatedMarketplace, listingData);
|
||||||
|
logger.info(`Background deleteListing complete for marketplace "${marketplace.name}"`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`Background deleteListing failed for marketplace "${marketplace.name}": ${err.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
work();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncItems(marketplace, user) {
|
||||||
|
setMarketplaceState(marketplace._id, 'syncing', user).catch((err) =>
|
||||||
|
logger.warn(`Failed to set marketplace syncing state: ${err.message}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const work = async () => {
|
||||||
|
try {
|
||||||
|
const provider = getProvider(marketplace);
|
||||||
|
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||||
|
logger.info(
|
||||||
|
`Starting item sync for marketplace "${marketplace.name}" (${marketplace.provider})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingListings = await listingModel
|
||||||
|
.find({
|
||||||
|
marketplace: authenticatedMarketplace._id,
|
||||||
|
'state.type': { $ne: 'deleted' },
|
||||||
|
})
|
||||||
|
.lean();
|
||||||
|
for (const listing of existingListings) {
|
||||||
|
await setListingState(listing._id, 'syncing', user).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingVarients = await listingVarientModel
|
||||||
|
.find({
|
||||||
|
listing: { $in: existingListings.map((l) => l._id) },
|
||||||
|
})
|
||||||
|
.lean();
|
||||||
|
for (const varient of existingVarients) {
|
||||||
|
await setListingVarientState(varient._id, 'syncing', user).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const listing of existingListings) {
|
||||||
|
try {
|
||||||
|
const listingVarients = existingVarients.filter(
|
||||||
|
(varient) => String(varient.listing) === String(listing._id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!listingVarients.length) {
|
||||||
|
throw new Error('Listing has no varients to sync');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await provider.updateItem(
|
||||||
|
authenticatedMarketplace,
|
||||||
|
listing,
|
||||||
|
listingVarients
|
||||||
|
);
|
||||||
|
|
||||||
|
const listingUpdateData = {
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
state: listing.state || { type: 'draft' },
|
||||||
|
};
|
||||||
|
if (result?.url) {
|
||||||
|
listingUpdateData.url = result.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
await editObject({
|
||||||
|
model: listingModel,
|
||||||
|
id: listing._id,
|
||||||
|
updateData: listingUpdateData,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const varient of listingVarients) {
|
||||||
|
await editObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id: varient._id,
|
||||||
|
updateData: {
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
state: varient.state || { type: 'draft' },
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ _reference: listing._reference, action: 'synced', id: listing._id });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to sync listing ${listing._reference}: ${err.message}`);
|
||||||
|
await setListingState(
|
||||||
|
listing._id,
|
||||||
|
listing.state?.type || 'draft',
|
||||||
|
user,
|
||||||
|
err.message
|
||||||
|
).catch(() => {});
|
||||||
|
results.push({
|
||||||
|
_reference: listing._reference,
|
||||||
|
action: 'error',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Item sync complete for marketplace ${marketplace.name}: ${results.length} processed`
|
||||||
|
);
|
||||||
|
|
||||||
|
await recalculateMarketplaceState(marketplace, user);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`Background syncItems failed for marketplace "${marketplace.name}": ${err.message}`
|
||||||
|
);
|
||||||
|
await recalculateMarketplaceState(marketplace, user);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
work();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncOrders(marketplace, user, { startTime, endTime } = {}) {
|
||||||
|
setMarketplaceState(marketplace._id, 'syncing', user).catch((err) =>
|
||||||
|
logger.warn(`Failed to set marketplace syncing state: ${err.message}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const work = async () => {
|
||||||
|
try {
|
||||||
|
const provider = getProvider(marketplace);
|
||||||
|
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||||
|
logger.info(
|
||||||
|
`Starting order sync for marketplace "${marketplace.name}" (${marketplace.provider})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const externalOrders = await provider.syncOrders(authenticatedMarketplace, {
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
});
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const externalOrder of externalOrders) {
|
||||||
|
try {
|
||||||
|
const mapped = provider.mapOrderToSalesOrder(externalOrder);
|
||||||
|
const clientData = provider.mapBuyerToClient(externalOrder);
|
||||||
|
|
||||||
|
let client = await clientModel.findOne({
|
||||||
|
name: clientData.name,
|
||||||
|
marketplace: authenticatedMarketplace._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
client = await newObject({
|
||||||
|
model: clientModel,
|
||||||
|
newData: {
|
||||||
|
...clientData,
|
||||||
|
marketplace: authenticatedMarketplace._id,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
logger.debug(`Created client "${clientData.name}" from marketplace order`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingOrder = await salesOrderModel.findOne({
|
||||||
|
marketplace: authenticatedMarketplace._id,
|
||||||
|
_reference: mapped.externalId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingOrder) {
|
||||||
|
await editObject({
|
||||||
|
model: salesOrderModel,
|
||||||
|
id: existingOrder._id,
|
||||||
|
updateData: {
|
||||||
|
state: mapped.state,
|
||||||
|
totalAmount: mapped.totalAmount,
|
||||||
|
totalAmountWithTax: mapped.totalAmountWithTax,
|
||||||
|
shippingAmount: mapped.shippingAmount,
|
||||||
|
shippingAmountWithTax: mapped.shippingAmountWithTax,
|
||||||
|
grandTotalAmount: mapped.grandTotalAmount,
|
||||||
|
totalTaxAmount: mapped.totalTaxAmount,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
results.push({
|
||||||
|
externalId: mapped.externalId,
|
||||||
|
action: 'updated',
|
||||||
|
id: existingOrder._id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const salesOrder = await newObject({
|
||||||
|
model: salesOrderModel,
|
||||||
|
newData: {
|
||||||
|
_reference: mapped.externalId,
|
||||||
|
client: client._id,
|
||||||
|
marketplace: authenticatedMarketplace._id,
|
||||||
|
state: mapped.state,
|
||||||
|
totalAmount: mapped.totalAmount,
|
||||||
|
totalAmountWithTax: mapped.totalAmountWithTax,
|
||||||
|
shippingAmount: mapped.shippingAmount,
|
||||||
|
shippingAmountWithTax: mapped.shippingAmountWithTax,
|
||||||
|
grandTotalAmount: mapped.grandTotalAmount,
|
||||||
|
totalTaxAmount: mapped.totalTaxAmount,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
results.push({ externalId: mapped.externalId, action: 'created', id: salesOrder._id });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to process order: ${err.message}`);
|
||||||
|
results.push({ externalId: externalOrder.id, action: 'error', error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Order sync complete for marketplace ${marketplace.name}: ${results.length} processed`
|
||||||
|
);
|
||||||
|
|
||||||
|
await recalculateMarketplaceState(marketplace, user);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`Background syncOrders failed for marketplace "${marketplace.name}": ${err.message}`
|
||||||
|
);
|
||||||
|
await recalculateMarketplaceState(marketplace, user);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
work();
|
||||||
|
}
|
||||||
@ -24,6 +24,8 @@ import orderItemRoutes from './inventory/orderitems.js';
|
|||||||
import shipmentRoutes from './inventory/shipments.js';
|
import shipmentRoutes from './inventory/shipments.js';
|
||||||
import stockEventRoutes from './inventory/stockevents.js';
|
import stockEventRoutes from './inventory/stockevents.js';
|
||||||
import stockAuditRoutes from './inventory/stockaudits.js';
|
import stockAuditRoutes from './inventory/stockaudits.js';
|
||||||
|
import stockLocationRoutes from './inventory/stocklocations.js';
|
||||||
|
import stockTransferRoutes from './inventory/stocktransfers.js';
|
||||||
import auditLogRoutes from './management/auditlogs.js';
|
import auditLogRoutes from './management/auditlogs.js';
|
||||||
import noteTypeRoutes from './management/notetypes.js';
|
import noteTypeRoutes from './management/notetypes.js';
|
||||||
import documentSizesRoutes from './management/documentsizes.js';
|
import documentSizesRoutes from './management/documentsizes.js';
|
||||||
@ -39,6 +41,8 @@ import paymentRoutes from './finance/payments.js';
|
|||||||
import clientRoutes from './sales/clients.js';
|
import clientRoutes from './sales/clients.js';
|
||||||
import salesOrderRoutes from './sales/salesorders.js';
|
import salesOrderRoutes from './sales/salesorders.js';
|
||||||
import marketplaceRoutes from './sales/marketplaces.js';
|
import marketplaceRoutes from './sales/marketplaces.js';
|
||||||
|
import listingRoutes from './sales/listings.js';
|
||||||
|
import listingVarientRoutes from './sales/listingvarients.js';
|
||||||
import noteRoutes from './misc/notes.js';
|
import noteRoutes from './misc/notes.js';
|
||||||
import userNotifierRoutes from './misc/usernotifiers.js';
|
import userNotifierRoutes from './misc/usernotifiers.js';
|
||||||
import notificationRoutes from './misc/notifications.js';
|
import notificationRoutes from './misc/notifications.js';
|
||||||
@ -73,6 +77,8 @@ export {
|
|||||||
shipmentRoutes,
|
shipmentRoutes,
|
||||||
stockEventRoutes,
|
stockEventRoutes,
|
||||||
stockAuditRoutes,
|
stockAuditRoutes,
|
||||||
|
stockLocationRoutes,
|
||||||
|
stockTransferRoutes,
|
||||||
auditLogRoutes,
|
auditLogRoutes,
|
||||||
noteTypeRoutes,
|
noteTypeRoutes,
|
||||||
noteRoutes,
|
noteRoutes,
|
||||||
@ -89,6 +95,8 @@ export {
|
|||||||
clientRoutes,
|
clientRoutes,
|
||||||
salesOrderRoutes,
|
salesOrderRoutes,
|
||||||
marketplaceRoutes,
|
marketplaceRoutes,
|
||||||
|
listingRoutes,
|
||||||
|
listingVarientRoutes,
|
||||||
userNotifierRoutes,
|
userNotifierRoutes,
|
||||||
notificationRoutes,
|
notificationRoutes,
|
||||||
odataRoutes,
|
odataRoutes,
|
||||||
|
|||||||
@ -18,7 +18,15 @@ import {
|
|||||||
// list of filament stocks
|
// list of filament stocks
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
const allowedFilters = ['filamentSku', 'state', 'startingWeight', 'currentWeight', 'filamentSku._id'];
|
const allowedFilters = [
|
||||||
|
'filamentSku',
|
||||||
|
'state',
|
||||||
|
'startingWeight',
|
||||||
|
'currentWeight',
|
||||||
|
'filamentSku._id',
|
||||||
|
'stockLocation',
|
||||||
|
'stockLocation._id',
|
||||||
|
];
|
||||||
const filter = getFilter(req.query, allowedFilters);
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
listFilamentStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
listFilamentStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,7 +18,15 @@ import {
|
|||||||
// list of part stocks
|
// list of part stocks
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
const allowedFilters = ['partSku', 'state', 'startingQuantity', 'currentQuantity', 'partSku._id'];
|
const allowedFilters = [
|
||||||
|
'partSku',
|
||||||
|
'state',
|
||||||
|
'startingQuantity',
|
||||||
|
'currentQuantity',
|
||||||
|
'partSku._id',
|
||||||
|
'stockLocation',
|
||||||
|
'stockLocation._id',
|
||||||
|
];
|
||||||
const filter = getFilter(req.query, allowedFilters);
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
listPartStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
listPartStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,7 +18,14 @@ import {
|
|||||||
|
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
const allowedFilters = ['productSku', 'state', 'currentQuantity', 'productSku._id'];
|
const allowedFilters = [
|
||||||
|
'productSku',
|
||||||
|
'state',
|
||||||
|
'currentQuantity',
|
||||||
|
'productSku._id',
|
||||||
|
'stockLocation',
|
||||||
|
'stockLocation._id',
|
||||||
|
];
|
||||||
const filter = getFilter(req.query, allowedFilters);
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
listProductStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
listProductStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
});
|
});
|
||||||
|
|||||||
64
src/routes/inventory/stocklocations.js
Normal file
64
src/routes/inventory/stocklocations.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { isAuthenticated } from '../../keycloak.js';
|
||||||
|
import { getFilter, convertPropertiesString } from '../../utils.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
import {
|
||||||
|
listStockLocationsRouteHandler,
|
||||||
|
getStockLocationRouteHandler,
|
||||||
|
editStockLocationRouteHandler,
|
||||||
|
editMultipleStockLocationsRouteHandler,
|
||||||
|
newStockLocationRouteHandler,
|
||||||
|
deleteStockLocationRouteHandler,
|
||||||
|
listStockLocationsByPropertiesRouteHandler,
|
||||||
|
getStockLocationStatsRouteHandler,
|
||||||
|
getStockLocationHistoryRouteHandler,
|
||||||
|
} from '../../services/inventory/stocklocations.js';
|
||||||
|
|
||||||
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
|
const allowedFilters = ['name'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
|
listStockLocationsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
|
const allowedFilters = ['name'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
|
var masterFilter = {};
|
||||||
|
if (req.query.masterFilter) {
|
||||||
|
masterFilter = JSON.parse(req.query.masterFilter);
|
||||||
|
}
|
||||||
|
listStockLocationsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', isAuthenticated, (req, res) => {
|
||||||
|
newStockLocationRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/stats', isAuthenticated, (req, res) => {
|
||||||
|
getStockLocationStatsRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/history', isAuthenticated, (req, res) => {
|
||||||
|
getStockLocationHistoryRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
|
getStockLocationRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/', isAuthenticated, async (req, res) => {
|
||||||
|
editMultipleStockLocationsRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', isAuthenticated, async (req, res) => {
|
||||||
|
editStockLocationRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', isAuthenticated, async (req, res) => {
|
||||||
|
deleteStockLocationRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
69
src/routes/inventory/stocktransfers.js
Normal file
69
src/routes/inventory/stocktransfers.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { isAuthenticated } from '../../keycloak.js';
|
||||||
|
import { getFilter, convertPropertiesString } from '../../utils.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
import {
|
||||||
|
listStockTransfersRouteHandler,
|
||||||
|
getStockTransferRouteHandler,
|
||||||
|
editStockTransferRouteHandler,
|
||||||
|
editMultipleStockTransfersRouteHandler,
|
||||||
|
newStockTransferRouteHandler,
|
||||||
|
deleteStockTransferRouteHandler,
|
||||||
|
postStockTransferRouteHandler,
|
||||||
|
listStockTransfersByPropertiesRouteHandler,
|
||||||
|
getStockTransferStatsRouteHandler,
|
||||||
|
getStockTransferHistoryRouteHandler,
|
||||||
|
} from '../../services/inventory/stocktransfers.js';
|
||||||
|
|
||||||
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
|
const allowedFilters = ['state', 'state.type', 'postedAt'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
|
listStockTransfersRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
|
const allowedFilters = ['state.type'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
|
var masterFilter = {};
|
||||||
|
if (req.query.masterFilter) {
|
||||||
|
masterFilter = JSON.parse(req.query.masterFilter);
|
||||||
|
}
|
||||||
|
listStockTransfersByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', isAuthenticated, (req, res) => {
|
||||||
|
newStockTransferRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/stats', isAuthenticated, (req, res) => {
|
||||||
|
getStockTransferStatsRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/history', isAuthenticated, (req, res) => {
|
||||||
|
getStockTransferHistoryRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
|
getStockTransferRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/', isAuthenticated, async (req, res) => {
|
||||||
|
editMultipleStockTransfersRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', isAuthenticated, async (req, res) => {
|
||||||
|
editStockTransferRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', isAuthenticated, async (req, res) => {
|
||||||
|
deleteStockTransferRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/post', isAuthenticated, async (req, res) => {
|
||||||
|
postStockTransferRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
83
src/routes/sales/listings.js
Normal file
83
src/routes/sales/listings.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { isAuthenticated } from '../../keycloak.js';
|
||||||
|
import { getFilter, convertPropertiesString } from '../../utils.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
import {
|
||||||
|
listListingsRouteHandler,
|
||||||
|
getListingRouteHandler,
|
||||||
|
editListingRouteHandler,
|
||||||
|
newListingRouteHandler,
|
||||||
|
deleteListingRouteHandler,
|
||||||
|
listListingsByPropertiesRouteHandler,
|
||||||
|
getListingStatsRouteHandler,
|
||||||
|
getListingHistoryRouteHandler,
|
||||||
|
publishListingRouteHandler,
|
||||||
|
unpublishListingRouteHandler,
|
||||||
|
} from '../../services/sales/listings.js';
|
||||||
|
|
||||||
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
|
const allowedFilters = [
|
||||||
|
'product',
|
||||||
|
'vendor',
|
||||||
|
'stockLocation',
|
||||||
|
'marketplace',
|
||||||
|
'state',
|
||||||
|
'state.type',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
];
|
||||||
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
|
listListingsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
|
const allowedFilters = [
|
||||||
|
'product',
|
||||||
|
'vendor',
|
||||||
|
'stockLocation',
|
||||||
|
'marketplace',
|
||||||
|
'state',
|
||||||
|
'state.type',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
];
|
||||||
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
|
listListingsByPropertiesRouteHandler(req, res, properties, filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', isAuthenticated, (req, res) => {
|
||||||
|
newListingRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/stats', isAuthenticated, (req, res) => {
|
||||||
|
getListingStatsRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/history', isAuthenticated, (req, res) => {
|
||||||
|
getListingHistoryRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/publish', isAuthenticated, (req, res) => {
|
||||||
|
publishListingRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/unpublish', isAuthenticated, (req, res) => {
|
||||||
|
unpublishListingRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
|
getListingRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', isAuthenticated, async (req, res) => {
|
||||||
|
editListingRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', isAuthenticated, async (req, res) => {
|
||||||
|
deleteListingRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
83
src/routes/sales/listingvarients.js
Normal file
83
src/routes/sales/listingvarients.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { isAuthenticated } from '../../keycloak.js';
|
||||||
|
import { getFilter, convertPropertiesString } from '../../utils.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
import {
|
||||||
|
listListingVarientsRouteHandler,
|
||||||
|
getListingVarientRouteHandler,
|
||||||
|
editListingVarientRouteHandler,
|
||||||
|
newListingVarientRouteHandler,
|
||||||
|
deleteListingVarientRouteHandler,
|
||||||
|
listListingVarientsByPropertiesRouteHandler,
|
||||||
|
getListingVarientStatsRouteHandler,
|
||||||
|
getListingVarientHistoryRouteHandler,
|
||||||
|
publishListingVarientRouteHandler,
|
||||||
|
unpublishListingVarientRouteHandler,
|
||||||
|
} from '../../services/sales/listingvarients.js';
|
||||||
|
|
||||||
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
|
const allowedFilters = [
|
||||||
|
'listing',
|
||||||
|
'listing._id',
|
||||||
|
'product',
|
||||||
|
'productSku',
|
||||||
|
'state',
|
||||||
|
'state.type',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
];
|
||||||
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
|
listListingVarientsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
|
const allowedFilters = [
|
||||||
|
'listing',
|
||||||
|
'listing._id',
|
||||||
|
'product',
|
||||||
|
'productSku',
|
||||||
|
'state',
|
||||||
|
'state.type',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
];
|
||||||
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
|
listListingVarientsByPropertiesRouteHandler(req, res, properties, filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', isAuthenticated, (req, res) => {
|
||||||
|
newListingVarientRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/stats', isAuthenticated, (req, res) => {
|
||||||
|
getListingVarientStatsRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/history', isAuthenticated, (req, res) => {
|
||||||
|
getListingVarientHistoryRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/publish', isAuthenticated, (req, res) => {
|
||||||
|
publishListingVarientRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/unpublish', isAuthenticated, (req, res) => {
|
||||||
|
unpublishListingVarientRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
|
getListingVarientRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', isAuthenticated, async (req, res) => {
|
||||||
|
editListingVarientRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', isAuthenticated, async (req, res) => {
|
||||||
|
deleteListingVarientRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -12,19 +12,24 @@ import {
|
|||||||
listMarketplacesByPropertiesRouteHandler,
|
listMarketplacesByPropertiesRouteHandler,
|
||||||
getMarketplaceStatsRouteHandler,
|
getMarketplaceStatsRouteHandler,
|
||||||
getMarketplaceHistoryRouteHandler,
|
getMarketplaceHistoryRouteHandler,
|
||||||
|
getMarketplaceAuthUrlRouteHandler,
|
||||||
|
exchangeMarketplaceAuthCodeRouteHandler,
|
||||||
|
refreshMarketplaceAuthRouteHandler,
|
||||||
|
syncMarketplaceItemsRouteHandler,
|
||||||
|
syncMarketplaceOrdersRouteHandler,
|
||||||
|
marketplaceWebhookRouteHandler,
|
||||||
} from '../../services/sales/marketplaces.js';
|
} from '../../services/sales/marketplaces.js';
|
||||||
|
|
||||||
// list of marketplaces
|
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
const allowedFilters = ['name', 'provider', 'active', 'createdAt', 'updatedAt'];
|
const allowedFilters = ['name', 'provider', 'active', 'connected', 'state.type', 'createdAt', 'updatedAt'];
|
||||||
const filter = getFilter(req.query, allowedFilters);
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
listMarketplacesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
listMarketplacesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/properties', isAuthenticated, (req, res) => {
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
let properties = convertPropertiesString(req.query.properties);
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
const allowedFilters = ['name', 'provider', 'active', 'createdAt', 'updatedAt'];
|
const allowedFilters = ['name', 'provider', 'active', 'connected', 'state.type', 'createdAt', 'updatedAt'];
|
||||||
const filter = getFilter(req.query, allowedFilters, false);
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
listMarketplacesByPropertiesRouteHandler(req, res, properties, filter);
|
listMarketplacesByPropertiesRouteHandler(req, res, properties, filter);
|
||||||
});
|
});
|
||||||
@ -33,16 +38,39 @@ router.post('/', isAuthenticated, (req, res) => {
|
|||||||
newMarketplaceRouteHandler(req, res);
|
newMarketplaceRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
// get marketplace stats
|
|
||||||
router.get('/stats', isAuthenticated, (req, res) => {
|
router.get('/stats', isAuthenticated, (req, res) => {
|
||||||
getMarketplaceStatsRouteHandler(req, res);
|
getMarketplaceStatsRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
// get marketplaces history
|
|
||||||
router.get('/history', isAuthenticated, (req, res) => {
|
router.get('/history', isAuthenticated, (req, res) => {
|
||||||
getMarketplaceHistoryRouteHandler(req, res);
|
getMarketplaceHistoryRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/:id/auth/url', isAuthenticated, (req, res) => {
|
||||||
|
getMarketplaceAuthUrlRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/auth/exchange', isAuthenticated, (req, res) => {
|
||||||
|
exchangeMarketplaceAuthCodeRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/auth/refresh', isAuthenticated, (req, res) => {
|
||||||
|
refreshMarketplaceAuthRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/sync/items', isAuthenticated, (req, res) => {
|
||||||
|
syncMarketplaceItemsRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/sync/orders', isAuthenticated, (req, res) => {
|
||||||
|
syncMarketplaceOrdersRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Webhook endpoint — no auth, provider verifies via signature
|
||||||
|
router.post('/:id/hook', (req, res) => {
|
||||||
|
marketplaceWebhookRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/:id', isAuthenticated, (req, res) => {
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
getMarketplaceRouteHandler(req, res);
|
getMarketplaceRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export const listFilamentStocksRouteHandler = async (
|
|||||||
search,
|
search,
|
||||||
sort,
|
sort,
|
||||||
order,
|
order,
|
||||||
populate: [{ path: 'filamentSku' }],
|
populate: [{ path: 'filamentSku' }, { path: 'stockLocation' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
@ -60,7 +60,7 @@ export const listFilamentStocksByPropertiesRouteHandler = async (
|
|||||||
model: filamentStockModel,
|
model: filamentStockModel,
|
||||||
properties,
|
properties,
|
||||||
filter,
|
filter,
|
||||||
populate: ['filamentSku'],
|
populate: ['filamentSku', 'stockLocation'],
|
||||||
masterFilter,
|
masterFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -79,7 +79,7 @@ export const getFilamentStockRouteHandler = async (req, res) => {
|
|||||||
const result = await getObject({
|
const result = await getObject({
|
||||||
model: filamentStockModel,
|
model: filamentStockModel,
|
||||||
id,
|
id,
|
||||||
populate: [{ path: 'filamentSku' }],
|
populate: [{ path: 'filamentSku' }, { path: 'stockLocation' }],
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
logger.warn(`Filament Stock not found with supplied id.`);
|
logger.warn(`Filament Stock not found with supplied id.`);
|
||||||
@ -95,8 +95,9 @@ export const editFilamentStockRouteHandler = async (req, res) => {
|
|||||||
|
|
||||||
logger.trace(`Filament Stock with ID: ${id}`);
|
logger.trace(`Filament Stock with ID: ${id}`);
|
||||||
|
|
||||||
const updateData = {};
|
const updateData = {
|
||||||
// Create audit log before updating
|
stockLocation: req.body.stockLocation,
|
||||||
|
};
|
||||||
const result = await editObject({
|
const result = await editObject({
|
||||||
model: filamentStockModel,
|
model: filamentStockModel,
|
||||||
id,
|
id,
|
||||||
@ -148,6 +149,7 @@ export const newFilamentStockRouteHandler = async (req, res) => {
|
|||||||
currentWeight: req.body.currentWeight,
|
currentWeight: req.body.currentWeight,
|
||||||
filamentSku: req.body.filamentSku,
|
filamentSku: req.body.filamentSku,
|
||||||
state: req.body.state,
|
state: req.body.state,
|
||||||
|
stockLocation: req.body.stockLocation,
|
||||||
};
|
};
|
||||||
const result = await newObject({
|
const result = await newObject({
|
||||||
model: filamentStockModel,
|
model: filamentStockModel,
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export const listPartStocksRouteHandler = async (
|
|||||||
search,
|
search,
|
||||||
sort,
|
sort,
|
||||||
order,
|
order,
|
||||||
populate: [{ path: 'partSku' }],
|
populate: [{ path: 'partSku' }, { path: 'stockLocation' }, { path: 'source' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
@ -60,7 +60,7 @@ export const listPartStocksByPropertiesRouteHandler = async (
|
|||||||
model: partStockModel,
|
model: partStockModel,
|
||||||
properties,
|
properties,
|
||||||
filter,
|
filter,
|
||||||
populate: ['partSku'],
|
populate: ['partSku', 'stockLocation', 'source'],
|
||||||
masterFilter,
|
masterFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -79,7 +79,7 @@ export const getPartStockRouteHandler = async (req, res) => {
|
|||||||
const result = await getObject({
|
const result = await getObject({
|
||||||
model: partStockModel,
|
model: partStockModel,
|
||||||
id,
|
id,
|
||||||
populate: [{ path: 'partSku' }],
|
populate: [{ path: 'partSku' }, { path: 'stockLocation' }, { path: 'source' }],
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
logger.warn(`Part Stock not found with supplied id.`);
|
logger.warn(`Part Stock not found with supplied id.`);
|
||||||
@ -96,7 +96,6 @@ export const editPartStockRouteHandler = async (req, res) => {
|
|||||||
logger.trace(`Part Stock with ID: ${id}`);
|
logger.trace(`Part Stock with ID: ${id}`);
|
||||||
|
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
// Create audit log before updating
|
|
||||||
const result = await editObject({
|
const result = await editObject({
|
||||||
model: partStockModel,
|
model: partStockModel,
|
||||||
id,
|
id,
|
||||||
@ -148,6 +147,9 @@ export const newPartStockRouteHandler = async (req, res) => {
|
|||||||
currentQuantity: req.body.currentQuantity,
|
currentQuantity: req.body.currentQuantity,
|
||||||
partSku: req.body.partSku,
|
partSku: req.body.partSku,
|
||||||
state: req.body.state,
|
state: req.body.state,
|
||||||
|
sourceType: req.body.sourceType,
|
||||||
|
source: req.body.source,
|
||||||
|
stockLocation: req.body.stockLocation,
|
||||||
};
|
};
|
||||||
const result = await newObject({
|
const result = await newObject({
|
||||||
model: partStockModel,
|
model: partStockModel,
|
||||||
|
|||||||
@ -38,7 +38,11 @@ export const listProductStocksRouteHandler = async (
|
|||||||
search,
|
search,
|
||||||
sort,
|
sort,
|
||||||
order,
|
order,
|
||||||
populate: [{ path: 'productSku' }, { path: 'partStocks.partStock' }],
|
populate: [
|
||||||
|
{ path: 'productSku' },
|
||||||
|
{ path: 'partStocks.partStock' },
|
||||||
|
{ path: 'stockLocation' },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
@ -62,7 +66,7 @@ export const listProductStocksByPropertiesRouteHandler = async (
|
|||||||
model: productStockModel,
|
model: productStockModel,
|
||||||
properties,
|
properties,
|
||||||
filter,
|
filter,
|
||||||
populate: ['productSku', 'partStocks.partStock'],
|
populate: ['productSku', 'partStocks.partStock', 'stockLocation'],
|
||||||
masterFilter,
|
masterFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -81,7 +85,12 @@ export const getProductStockRouteHandler = async (req, res) => {
|
|||||||
const result = await getObject({
|
const result = await getObject({
|
||||||
model: productStockModel,
|
model: productStockModel,
|
||||||
id,
|
id,
|
||||||
populate: [{ path: 'partStocks.partSku' }, { path: 'partStocks.partStock' }, { path: 'productSku' }],
|
populate: [
|
||||||
|
{ path: 'partStocks.partSku' },
|
||||||
|
{ path: 'partStocks.partStock' },
|
||||||
|
{ path: 'productSku' },
|
||||||
|
{ path: 'stockLocation' },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
logger.warn(`Product Stock not found with supplied id.`);
|
logger.warn(`Product Stock not found with supplied id.`);
|
||||||
@ -111,6 +120,7 @@ export const editProductStockRouteHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
|
stockLocation: req.body?.stockLocation,
|
||||||
partStocks: req.body?.partStocks?.map((partStock) => ({
|
partStocks: req.body?.partStocks?.map((partStock) => ({
|
||||||
quantity: partStock.quantity,
|
quantity: partStock.quantity,
|
||||||
partStock: partStock.partStock,
|
partStock: partStock.partStock,
|
||||||
@ -173,6 +183,7 @@ export const newProductStockRouteHandler = async (req, res) => {
|
|||||||
currentQuantity: req.body.currentQuantity,
|
currentQuantity: req.body.currentQuantity,
|
||||||
productSku: req.body.productSku,
|
productSku: req.body.productSku,
|
||||||
state: req.body.state ?? { type: 'draft' },
|
state: req.body.state ?? { type: 'draft' },
|
||||||
|
stockLocation: req.body.stockLocation,
|
||||||
partStocks: (productSku.parts || []).map((part) => ({
|
partStocks: (productSku.parts || []).map((part) => ({
|
||||||
partSku: part.partSku,
|
partSku: part.partSku,
|
||||||
quantity: part.quantity,
|
quantity: part.quantity,
|
||||||
|
|||||||
196
src/services/inventory/stocklocations.js
Normal file
196
src/services/inventory/stocklocations.js
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import config from '../../config.js';
|
||||||
|
import { stockLocationModel } from '../../database/schemas/inventory/stocklocation.schema.js';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import {
|
||||||
|
deleteObject,
|
||||||
|
listObjects,
|
||||||
|
getObject,
|
||||||
|
editObject,
|
||||||
|
editObjects,
|
||||||
|
newObject,
|
||||||
|
listObjectsByProperties,
|
||||||
|
getModelStats,
|
||||||
|
getModelHistory,
|
||||||
|
} from '../../database/database.js';
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('Stock Locations');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
export const listStockLocationsRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
page = 1,
|
||||||
|
limit = 25,
|
||||||
|
property = '',
|
||||||
|
filter = {},
|
||||||
|
search = '',
|
||||||
|
sort = '',
|
||||||
|
order = 'ascend'
|
||||||
|
) => {
|
||||||
|
const result = await listObjects({
|
||||||
|
model: stockLocationModel,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
property,
|
||||||
|
filter,
|
||||||
|
search,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing stock locations.');
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of stock locations (Page ${page}, Limit ${limit}). Count: ${result.length}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listStockLocationsByPropertiesRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
properties = '',
|
||||||
|
filter = {},
|
||||||
|
masterFilter = {}
|
||||||
|
) => {
|
||||||
|
const result = await listObjectsByProperties({
|
||||||
|
model: stockLocationModel,
|
||||||
|
properties,
|
||||||
|
filter,
|
||||||
|
masterFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing stock locations.');
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of stock locations. Count: ${result.length}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockLocationRouteHandler = async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const result = await getObject({
|
||||||
|
model: stockLocationModel,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
logger.warn(`Stock location not found with supplied id.`);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.debug(`Retrieved stock location with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editStockLocationRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
name: req.body.name,
|
||||||
|
address: req.body.address,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await editObject({
|
||||||
|
model: stockLocationModel,
|
||||||
|
id,
|
||||||
|
updateData,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('Error editing stock location:', result.error);
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Edited stock location with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editMultipleStockLocationsRouteHandler = async (req, res) => {
|
||||||
|
const updates = req.body.map((update) => ({
|
||||||
|
_id: update._id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!Array.isArray(updates)) {
|
||||||
|
return res.status(400).send({ error: 'Body must be an array of updates.', code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await editObjects({
|
||||||
|
model: stockLocationModel,
|
||||||
|
updates,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('Error editing stock locations:', result.error);
|
||||||
|
res.status(result.code || 500).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Edited ${updates.length} stock locations`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const newStockLocationRouteHandler = async (req, res) => {
|
||||||
|
const newData = {
|
||||||
|
name: req.body.name,
|
||||||
|
address: req.body.address,
|
||||||
|
};
|
||||||
|
const result = await newObject({
|
||||||
|
model: stockLocationModel,
|
||||||
|
newData,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('No stock location created:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`New stock location with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteStockLocationRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
const result = await deleteObject({
|
||||||
|
model: stockLocationModel,
|
||||||
|
id,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('No stock location deleted:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Deleted stock location with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockLocationStatsRouteHandler = async (req, res) => {
|
||||||
|
const result = await getModelStats({ model: stockLocationModel });
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error fetching stock location stats:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.trace('Stock location stats:', result);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockLocationHistoryRouteHandler = async (req, res) => {
|
||||||
|
const from = req.query.from;
|
||||||
|
const to = req.query.to;
|
||||||
|
const result = await getModelHistory({ model: stockLocationModel, from, to });
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error fetching stock location history:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.trace('Stock location history:', result);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
461
src/services/inventory/stocktransfers.js
Normal file
461
src/services/inventory/stocktransfers.js
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
import config from '../../config.js';
|
||||||
|
import { stockTransferModel } from '../../database/schemas/inventory/stocktransfer.schema.js';
|
||||||
|
import { stockLocationModel } from '../../database/schemas/inventory/stocklocation.schema.js';
|
||||||
|
import { filamentStockModel } from '../../database/schemas/inventory/filamentstock.schema.js';
|
||||||
|
import { partStockModel } from '../../database/schemas/inventory/partstock.schema.js';
|
||||||
|
import { productStockModel } from '../../database/schemas/inventory/productstock.schema.js';
|
||||||
|
import { stockEventModel } from '../../database/schemas/inventory/stockevent.schema.js';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import {
|
||||||
|
deleteObject,
|
||||||
|
listObjects,
|
||||||
|
getObject,
|
||||||
|
editObject,
|
||||||
|
editObjects,
|
||||||
|
newObject,
|
||||||
|
listObjectsByProperties,
|
||||||
|
getModelStats,
|
||||||
|
getModelHistory,
|
||||||
|
checkStates,
|
||||||
|
} from '../../database/database.js';
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('Stock Transfers');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
const normalizeLineInput = (l) => ({
|
||||||
|
fromStockType: l.fromStockType,
|
||||||
|
fromStock: l.fromStock?._id ?? l.fromStock,
|
||||||
|
quantity: Number(l.quantity),
|
||||||
|
toStockLocation: l.toStockLocation?._id ?? l.toStockLocation,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createStockEventsForLine({ transferId, fromId, fromType, toId, toType, qty, unit }) {
|
||||||
|
const ts = new Date();
|
||||||
|
await stockEventModel.insertMany([
|
||||||
|
{
|
||||||
|
value: -Math.abs(qty),
|
||||||
|
unit,
|
||||||
|
parent: fromId,
|
||||||
|
parentType: fromType,
|
||||||
|
owner: transferId,
|
||||||
|
ownerType: 'stockTransfer',
|
||||||
|
timestamp: ts,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: Math.abs(qty),
|
||||||
|
unit,
|
||||||
|
parent: toId,
|
||||||
|
parentType: toType,
|
||||||
|
owner: transferId,
|
||||||
|
ownerType: 'stockTransfer',
|
||||||
|
timestamp: ts,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executePostedLine(transferId, line) {
|
||||||
|
const toLocId = line.toStockLocation;
|
||||||
|
const loc = await stockLocationModel.findById(toLocId).lean();
|
||||||
|
if (!loc) {
|
||||||
|
throw new Error(`Unknown stock location: ${toLocId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(line.quantity > 0)) {
|
||||||
|
throw new Error('Line quantity must be positive');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.fromStockType === 'filamentStock') {
|
||||||
|
const src = await filamentStockModel.findById(line.fromStock);
|
||||||
|
if (!src) throw new Error('From filament stock not found');
|
||||||
|
const netAvail = src.currentWeight?.net ?? 0;
|
||||||
|
if (line.quantity > netAvail) {
|
||||||
|
throw new Error('Filament transfer quantity exceeds available net weight');
|
||||||
|
}
|
||||||
|
const tareBefore = Math.max(0, (src.currentWeight?.gross ?? 0) - (src.currentWeight?.net ?? 0));
|
||||||
|
const newNet = netAvail - line.quantity;
|
||||||
|
const ratio = netAvail > 0 ? newNet / netAvail : 0;
|
||||||
|
const newGross = (src.currentWeight?.gross ?? 0) * ratio;
|
||||||
|
await filamentStockModel.findByIdAndUpdate(src._id, {
|
||||||
|
$set: { 'currentWeight.net': newNet, 'currentWeight.gross': newGross },
|
||||||
|
});
|
||||||
|
|
||||||
|
const destWeight = {
|
||||||
|
net: line.quantity,
|
||||||
|
gross: line.quantity + tareBefore,
|
||||||
|
};
|
||||||
|
const dest = await filamentStockModel.create({
|
||||||
|
state: src.state,
|
||||||
|
startingWeight: destWeight,
|
||||||
|
currentWeight: destWeight,
|
||||||
|
filamentSku: src.filamentSku,
|
||||||
|
stockLocation: toLocId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createStockEventsForLine({
|
||||||
|
transferId,
|
||||||
|
fromId: src._id,
|
||||||
|
fromType: 'filamentStock',
|
||||||
|
toId: dest._id,
|
||||||
|
toType: 'filamentStock',
|
||||||
|
qty: line.quantity,
|
||||||
|
unit: 'g',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { toStockType: 'filamentStock', toStock: dest._id };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.fromStockType === 'partStock') {
|
||||||
|
const src = await partStockModel.findById(line.fromStock);
|
||||||
|
console.log(src);
|
||||||
|
if (!src) throw new Error('From part stock not found');
|
||||||
|
const currentQuantity = src.state.type === 'new' ? src.startingQuantity : src.currentQuantity;
|
||||||
|
if (line.quantity > currentQuantity) {
|
||||||
|
throw new Error('Part transfer quantity exceeds current quantity');
|
||||||
|
}
|
||||||
|
await partStockModel.findByIdAndUpdate(src._id, {
|
||||||
|
$inc: { currentQuantity: -line.quantity },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dest = await partStockModel.create({
|
||||||
|
partSku: src.partSku,
|
||||||
|
currentQuantity: line.quantity,
|
||||||
|
state: { type: 'new' },
|
||||||
|
sourceType: 'stockTransfer',
|
||||||
|
source: transferId,
|
||||||
|
stockLocation: toLocId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createStockEventsForLine({
|
||||||
|
transferId,
|
||||||
|
fromId: src._id,
|
||||||
|
fromType: 'partStock',
|
||||||
|
toId: dest._id,
|
||||||
|
toType: 'partStock',
|
||||||
|
qty: line.quantity,
|
||||||
|
unit: 'each',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { toStockType: 'partStock', toStock: dest._id };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.fromStockType === 'productStock') {
|
||||||
|
const src = await productStockModel.findById(line.fromStock);
|
||||||
|
if (!src) throw new Error('From product stock not found');
|
||||||
|
if (line.quantity > src.currentQuantity) {
|
||||||
|
throw new Error('Product transfer quantity exceeds current quantity');
|
||||||
|
}
|
||||||
|
await productStockModel.findByIdAndUpdate(src._id, {
|
||||||
|
$inc: { currentQuantity: -line.quantity },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dest = await productStockModel.create({
|
||||||
|
productSku: src.productSku,
|
||||||
|
currentQuantity: line.quantity,
|
||||||
|
state: { type: 'posted' },
|
||||||
|
postedAt: new Date(),
|
||||||
|
partStocks: [],
|
||||||
|
stockLocation: toLocId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createStockEventsForLine({
|
||||||
|
transferId,
|
||||||
|
fromId: src._id,
|
||||||
|
fromType: 'productStock',
|
||||||
|
toId: dest._id,
|
||||||
|
toType: 'productStock',
|
||||||
|
qty: line.quantity,
|
||||||
|
unit: 'each',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { toStockType: 'productStock', toStock: dest._id };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported from stock type: ${line.fromStockType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listStockTransfersRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
page = 1,
|
||||||
|
limit = 25,
|
||||||
|
property = '',
|
||||||
|
filter = {},
|
||||||
|
search = '',
|
||||||
|
sort = '',
|
||||||
|
order = 'ascend'
|
||||||
|
) => {
|
||||||
|
const result = await listObjects({
|
||||||
|
model: stockTransferModel,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
property,
|
||||||
|
filter,
|
||||||
|
search,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
populate: [
|
||||||
|
{ path: 'lines.fromStock' },
|
||||||
|
{ path: 'lines.toStockLocation' },
|
||||||
|
{ path: 'lines.toStock' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing stock transfers.');
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of stock transfers (Page ${page}, Limit ${limit}). Count: ${result.length}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listStockTransfersByPropertiesRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
properties = '',
|
||||||
|
filter = {},
|
||||||
|
masterFilter = {}
|
||||||
|
) => {
|
||||||
|
const result = await listObjectsByProperties({
|
||||||
|
model: stockTransferModel,
|
||||||
|
properties,
|
||||||
|
filter,
|
||||||
|
populate: [
|
||||||
|
{ path: 'lines.fromStock' },
|
||||||
|
{ path: 'lines.toStockLocation' },
|
||||||
|
{ path: 'lines.toStock' },
|
||||||
|
],
|
||||||
|
masterFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing stock transfers.');
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of stock transfers. Count: ${result.length}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockTransferRouteHandler = async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const result = await getObject({
|
||||||
|
model: stockTransferModel,
|
||||||
|
id,
|
||||||
|
populate: [
|
||||||
|
{ path: 'lines.fromStock' },
|
||||||
|
{ path: 'lines.toStockLocation' },
|
||||||
|
{ path: 'lines.toStock' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
logger.warn(`Stock transfer not found with supplied id.`);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.debug(`Retrieved stock transfer with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editStockTransferRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
const checkStatesResult = await checkStates({ model: stockTransferModel, id, states: ['draft'] });
|
||||||
|
|
||||||
|
if (checkStatesResult.error) {
|
||||||
|
logger.error('Error checking stock transfer state:', checkStatesResult.error);
|
||||||
|
res.status(checkStatesResult.code).send(checkStatesResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkStatesResult === false) {
|
||||||
|
logger.error('Stock transfer is not in draft state.');
|
||||||
|
res.status(400).send({ error: 'Stock transfer is not in draft state.', code: 400 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
lines: (req.body.lines || []).map((l) => normalizeLineInput(l)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await editObject({
|
||||||
|
model: stockTransferModel,
|
||||||
|
id,
|
||||||
|
updateData,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('Error editing stock transfer:', result.error);
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Edited stock transfer with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editMultipleStockTransfersRouteHandler = async (req, res) => {
|
||||||
|
const updates = req.body.map((update) => ({
|
||||||
|
_id: update._id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!Array.isArray(updates)) {
|
||||||
|
return res.status(400).send({ error: 'Body must be an array of updates.', code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await editObjects({
|
||||||
|
model: stockTransferModel,
|
||||||
|
updates,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('Error editing stock transfers:', result.error);
|
||||||
|
res.status(result.code || 500).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Edited ${updates.length} stock transfers`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const newStockTransferRouteHandler = async (req, res) => {
|
||||||
|
const newData = {
|
||||||
|
state: req.body.state ?? { type: 'draft' },
|
||||||
|
lines: (req.body.lines || []).map((l) => normalizeLineInput(l)),
|
||||||
|
};
|
||||||
|
const result = await newObject({
|
||||||
|
model: stockTransferModel,
|
||||||
|
newData,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('No stock transfer created:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`New stock transfer with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteStockTransferRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
const checkStatesResult = await checkStates({ model: stockTransferModel, id, states: ['draft'] });
|
||||||
|
|
||||||
|
if (checkStatesResult.error) {
|
||||||
|
logger.error('Error checking stock transfer state:', checkStatesResult.error);
|
||||||
|
res.status(checkStatesResult.code).send(checkStatesResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkStatesResult === false) {
|
||||||
|
logger.error('Stock transfer is not in draft state.');
|
||||||
|
res.status(400).send({ error: 'Stock transfer is not in draft state.', code: 400 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deleteObject({
|
||||||
|
model: stockTransferModel,
|
||||||
|
id,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('No stock transfer deleted:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Deleted stock transfer with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postStockTransferRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
const checkStatesResult = await checkStates({ model: stockTransferModel, id, states: ['draft'] });
|
||||||
|
|
||||||
|
if (checkStatesResult.error) {
|
||||||
|
logger.error('Error checking stock transfer state:', checkStatesResult.error);
|
||||||
|
res.status(checkStatesResult.code).send(checkStatesResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkStatesResult === false) {
|
||||||
|
logger.error('Stock transfer is not in draft state.');
|
||||||
|
res.status(400).send({ error: 'Stock transfer is not in draft state.', code: 400 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await stockTransferModel.findById(id);
|
||||||
|
if (!doc) {
|
||||||
|
return res.status(404).send({ error: 'Stock transfer not found.', code: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc.lines?.length) {
|
||||||
|
return res.status(400).send({ error: 'Stock transfer has no lines.', code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedLines = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const line of doc.lines) {
|
||||||
|
const plain = line.toObject();
|
||||||
|
const { toStockType, toStock } = await executePostedLine(doc._id, plain);
|
||||||
|
updatedLines.push({
|
||||||
|
...plain,
|
||||||
|
toStockType,
|
||||||
|
toStock,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const posted = await stockTransferModel
|
||||||
|
.findByIdAndUpdate(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
state: { type: 'posted' },
|
||||||
|
postedAt: new Date(),
|
||||||
|
lines: updatedLines,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ new: true }
|
||||||
|
)
|
||||||
|
.populate([
|
||||||
|
{ path: 'lines.fromStock' },
|
||||||
|
{ path: 'lines.toStockLocation' },
|
||||||
|
{ path: 'lines.toStock' },
|
||||||
|
])
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
logger.debug(`Posted stock transfer with ID: ${id}`);
|
||||||
|
res.send(posted);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error posting stock transfer:', err);
|
||||||
|
res.status(400).send({ error: err.message || 'Failed to post stock transfer', code: 400 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockTransferStatsRouteHandler = async (req, res) => {
|
||||||
|
const result = await getModelStats({ model: stockTransferModel });
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error fetching stock transfer stats:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.trace('Stock transfer stats:', result);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockTransferHistoryRouteHandler = async (req, res) => {
|
||||||
|
const from = req.query.from;
|
||||||
|
const to = req.query.to;
|
||||||
|
const result = await getModelHistory({ model: stockTransferModel, from, to });
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error fetching stock transfer history:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.trace('Stock transfer history:', result);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
@ -23,6 +23,8 @@ export const EXPORT_FILTER_BY_TYPE = {
|
|||||||
orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'sku._id', 'shipment._id'],
|
orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'sku._id', 'shipment._id'],
|
||||||
shipment: ['order._id', 'orderType', 'courierService._id'],
|
shipment: ['order._id', 'orderType', 'courierService._id'],
|
||||||
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
||||||
|
stockLocation: ['name', 'address'],
|
||||||
|
stockTransfer: ['state.type', 'postedAt'],
|
||||||
stockAudit: ['filamentStock._id', 'partStock._id'],
|
stockAudit: ['filamentStock._id', 'partStock._id'],
|
||||||
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
|
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
|
||||||
documentTemplate: ['parent._id', 'documentSize._id'],
|
documentTemplate: ['parent._id', 'documentSize._id'],
|
||||||
|
|||||||
474
src/services/sales/listings.js
Normal file
474
src/services/sales/listings.js
Normal file
@ -0,0 +1,474 @@
|
|||||||
|
import config from '../../config.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 log4js from 'log4js';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import {
|
||||||
|
deleteObject,
|
||||||
|
listObjects,
|
||||||
|
getObject,
|
||||||
|
editObject,
|
||||||
|
newObject,
|
||||||
|
listObjectsByProperties,
|
||||||
|
getModelStats,
|
||||||
|
getModelHistory,
|
||||||
|
checkStates,
|
||||||
|
} from '../../database/database.js';
|
||||||
|
import {
|
||||||
|
hasIntegration,
|
||||||
|
createListing as createExternalListing,
|
||||||
|
updateListing as updateExternalListing,
|
||||||
|
deleteListing as deleteExternalListing,
|
||||||
|
publishMarketplaceOfferForSku,
|
||||||
|
withdrawMarketplaceOfferForSku,
|
||||||
|
} from '../../integrations/marketplaceworker.js';
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('Listings');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
function pushToMarketplace(marketplaceId, listingData, user, { isNew = false, isDelete = false } = {}) {
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const marketplace = await marketplaceModel.findById(marketplaceId);
|
||||||
|
if (!marketplace || !marketplace.active || !hasIntegration(marketplace.provider)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDelete) {
|
||||||
|
deleteExternalListing(marketplace, user, listingData);
|
||||||
|
} else if (isNew) {
|
||||||
|
createExternalListing(marketplace, user, listingData);
|
||||||
|
} else {
|
||||||
|
updateExternalListing(marketplace, user, listingData);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to initiate marketplace sync for listing: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listListingsRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
page = 1,
|
||||||
|
limit = 25,
|
||||||
|
property = '',
|
||||||
|
filter = {},
|
||||||
|
search = '',
|
||||||
|
sort = '',
|
||||||
|
order = 'ascend'
|
||||||
|
) => {
|
||||||
|
const result = await listObjects({
|
||||||
|
model: listingModel,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
property,
|
||||||
|
filter,
|
||||||
|
search,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing listings.');
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of listings (Page ${page}, Limit ${limit}). Count: ${result.length}.`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listListingsByPropertiesRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
properties = '',
|
||||||
|
filter = {}
|
||||||
|
) => {
|
||||||
|
const result = await listObjectsByProperties({
|
||||||
|
model: listingModel,
|
||||||
|
properties,
|
||||||
|
filter,
|
||||||
|
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing listings.');
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of listings. Count: ${result.length}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getListingRouteHandler = async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const result = await getObject({
|
||||||
|
model: listingModel,
|
||||||
|
id,
|
||||||
|
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||||
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
logger.warn(`Listing not found with supplied id.`);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.debug(`Retrieved listing with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editListingRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
logger.trace(`Listing with ID: ${id}`);
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
product: req.body.product,
|
||||||
|
vendor: req.body.vendor,
|
||||||
|
stockLocation: req.body.stockLocation,
|
||||||
|
marketplace: req.body.marketplace,
|
||||||
|
title: req.body.title,
|
||||||
|
url: req.body.url,
|
||||||
|
};
|
||||||
|
const result = await editObject({
|
||||||
|
model: listingModel,
|
||||||
|
id,
|
||||||
|
updateData,
|
||||||
|
user: req.user,
|
||||||
|
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('Error editing listing:', result.error);
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketplaceId = result.marketplace?._id || result.marketplace;
|
||||||
|
if (marketplaceId) {
|
||||||
|
pushToMarketplace(marketplaceId, { _id: id }, req.user, { isNew: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Edited listing with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const newListingRouteHandler = async (req, res) => {
|
||||||
|
const newData = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
product: req.body.product,
|
||||||
|
vendor: req.body.vendor,
|
||||||
|
stockLocation: req.body.stockLocation,
|
||||||
|
marketplace: req.body.marketplace,
|
||||||
|
title: req.body.title,
|
||||||
|
state: req.body.state || { type: 'draft' },
|
||||||
|
url: req.body.url,
|
||||||
|
};
|
||||||
|
const result = await newObject({
|
||||||
|
model: listingModel,
|
||||||
|
newData,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('No listing created:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await newObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
newData: {
|
||||||
|
listing: result._id,
|
||||||
|
state: { type: 'draft' },
|
||||||
|
product: req.body.product || undefined,
|
||||||
|
},
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
logger.debug(`Created default listing varient for listing ${result._id}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to create default listing varient: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMarketplaceId = result.marketplace?._id || result.marketplace;
|
||||||
|
if (newMarketplaceId) {
|
||||||
|
pushToMarketplace(newMarketplaceId, { _id: result._id }, req.user, { isNew: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`New listing with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteListingRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
logger.trace(`Listing with ID: ${id}`);
|
||||||
|
|
||||||
|
const listing = await getObject({ model: listingModel, id });
|
||||||
|
if (listing?.error) {
|
||||||
|
logger.warn('Listing not found for deletion.');
|
||||||
|
return res.status(listing.code).send(listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const varients = await listingVarientModel.find({ listing: id });
|
||||||
|
for (const varient of varients) {
|
||||||
|
try {
|
||||||
|
await deleteObject({ model: listingVarientModel, id: varient._id, user: req.user });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to delete listing varient ${varient._id}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deleteObject({
|
||||||
|
model: listingModel,
|
||||||
|
id,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('No listing deleted:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delMarketplaceId = listing.marketplace?._id || listing.marketplace;
|
||||||
|
if (delMarketplaceId) {
|
||||||
|
pushToMarketplace(delMarketplaceId, listing, req.user, { isDelete: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Deleted listing with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getListingStatsRouteHandler = async (req, res) => {
|
||||||
|
const result = await getModelStats({ model: listingModel });
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error fetching listing stats:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.trace('Listing stats:', result);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getListingHistoryRouteHandler = async (req, res) => {
|
||||||
|
const from = req.query.from;
|
||||||
|
const to = req.query.to;
|
||||||
|
const result = await getModelHistory({ model: listingModel, from, to });
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error fetching listing history:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.trace('Listing history:', result);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const publishListingRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
const stateOk = await checkStates({
|
||||||
|
model: listingModel,
|
||||||
|
id,
|
||||||
|
states: ['draft', 'inactive'],
|
||||||
|
});
|
||||||
|
if (stateOk?.error) {
|
||||||
|
logger.error('Error checking listing state:', stateOk.error);
|
||||||
|
return res.status(stateOk.code).send(stateOk);
|
||||||
|
}
|
||||||
|
if (stateOk === false) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing must be in draft or inactive state to publish offers.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncingCheck = await checkStates({
|
||||||
|
model: listingModel,
|
||||||
|
id,
|
||||||
|
states: ['syncing'],
|
||||||
|
});
|
||||||
|
if (syncingCheck === true) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing is syncing; wait for sync to finish before publishing.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const listing = await listingModel
|
||||||
|
.findById(id)
|
||||||
|
.populate(['marketplace', 'vendor', 'stockLocation'])
|
||||||
|
.lean();
|
||||||
|
if (!listing) {
|
||||||
|
return res.status(404).send({ error: 'Listing not found.', code: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!listing.stockLocation) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing must have a stock location before publishing.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketplace = listing.marketplace;
|
||||||
|
if (!marketplace?._id) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing has no marketplace; cannot publish offers.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!marketplace.active) {
|
||||||
|
return res.status(400).send({ error: 'Marketplace is not active.', code: 400 });
|
||||||
|
}
|
||||||
|
if (!hasIntegration(marketplace.provider)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'No integration is configured for this marketplace.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const varients = await listingVarientModel.find({ listing: id }).lean();
|
||||||
|
const toPublish = varients.filter((v) => v._reference && v.state?.type !== 'active');
|
||||||
|
if (toPublish.length === 0) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'No variants to publish (all are already active or missing SKU).',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let firstListingId;
|
||||||
|
for (const v of toPublish) {
|
||||||
|
const apiResult = await publishMarketplaceOfferForSku(
|
||||||
|
marketplace,
|
||||||
|
req.user,
|
||||||
|
v._reference,
|
||||||
|
listing
|
||||||
|
);
|
||||||
|
await editObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id: v._id,
|
||||||
|
updateData: {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
state: { type: 'active' },
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
},
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
if (apiResult?.listingId && !firstListingId) {
|
||||||
|
firstListingId = apiResult.listingId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listingUpdate = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
state: { type: 'active' },
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (firstListingId) {
|
||||||
|
listingUpdate.url = `https://www.ebay.com/itm/${firstListingId}`;
|
||||||
|
}
|
||||||
|
await editObject({
|
||||||
|
model: listingModel,
|
||||||
|
id,
|
||||||
|
updateData: listingUpdate,
|
||||||
|
user: req.user,
|
||||||
|
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await getObject({
|
||||||
|
model: listingModel,
|
||||||
|
id,
|
||||||
|
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||||
|
});
|
||||||
|
res.send(updated);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Publish listing failed: ${err.message}`);
|
||||||
|
res.status(500).send({ error: err.message, code: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unpublishListingRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
const syncingCheck = await checkStates({
|
||||||
|
model: listingModel,
|
||||||
|
id,
|
||||||
|
states: ['syncing'],
|
||||||
|
});
|
||||||
|
if (syncingCheck === true) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing is syncing; wait for sync to finish before unpublishing.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const listing = await listingModel.findById(id).populate('marketplace').lean();
|
||||||
|
if (!listing) {
|
||||||
|
return res.status(404).send({ error: 'Listing not found.', code: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketplace = listing.marketplace;
|
||||||
|
if (!marketplace?._id) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing has no marketplace; cannot withdraw offers.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!marketplace.active) {
|
||||||
|
return res.status(400).send({ error: 'Marketplace is not active.', code: 400 });
|
||||||
|
}
|
||||||
|
if (!hasIntegration(marketplace.provider)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'No integration is configured for this marketplace.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const varients = await listingVarientModel.find({ listing: id }).lean();
|
||||||
|
const toUnpublish = varients.filter((v) => v._reference && v.state?.type === 'active');
|
||||||
|
if (toUnpublish.length === 0) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'No active variants to unpublish.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const v of toUnpublish) {
|
||||||
|
await withdrawMarketplaceOfferForSku(marketplace, req.user, v._reference);
|
||||||
|
await editObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id: v._id,
|
||||||
|
updateData: {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
state: { type: 'draft' },
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
},
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await editObject({
|
||||||
|
model: listingModel,
|
||||||
|
id,
|
||||||
|
updateData: {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
state: { type: 'draft' },
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
},
|
||||||
|
user: req.user,
|
||||||
|
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await getObject({
|
||||||
|
model: listingModel,
|
||||||
|
id,
|
||||||
|
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||||
|
});
|
||||||
|
res.send(updated);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Unpublish listing failed: ${err.message}`);
|
||||||
|
res.status(500).send({ error: err.message, code: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
435
src/services/sales/listingvarients.js
Normal file
435
src/services/sales/listingvarients.js
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
import config from '../../config.js';
|
||||||
|
import { listingVarientModel } from '../../database/schemas/sales/listingvarient.schema.js';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import {
|
||||||
|
deleteObject,
|
||||||
|
listObjects,
|
||||||
|
getObject,
|
||||||
|
editObject,
|
||||||
|
newObject,
|
||||||
|
listObjectsByProperties,
|
||||||
|
getModelStats,
|
||||||
|
getModelHistory,
|
||||||
|
checkStates,
|
||||||
|
} from '../../database/database.js';
|
||||||
|
import { listingModel } from '../../database/schemas/sales/listing.schema.js';
|
||||||
|
import {
|
||||||
|
hasIntegration,
|
||||||
|
publishMarketplaceOfferForSku,
|
||||||
|
withdrawMarketplaceOfferForSku,
|
||||||
|
} from '../../integrations/marketplaceworker.js';
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('ListingVarients');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
const POPULATE_FIELDS = ['listing', 'product', 'productSku', 'priceTaxRate'];
|
||||||
|
|
||||||
|
export const listListingVarientsRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
page = 1,
|
||||||
|
limit = 25,
|
||||||
|
property = '',
|
||||||
|
filter = {},
|
||||||
|
search = '',
|
||||||
|
sort = '',
|
||||||
|
order = 'ascend'
|
||||||
|
) => {
|
||||||
|
const result = await listObjects({
|
||||||
|
model: listingVarientModel,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
property,
|
||||||
|
filter,
|
||||||
|
search,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
populate: POPULATE_FIELDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing listing varients.');
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of listing varients (Page ${page}, Limit ${limit}). Count: ${result.length}.`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listListingVarientsByPropertiesRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
properties = '',
|
||||||
|
filter = {}
|
||||||
|
) => {
|
||||||
|
const result = await listObjectsByProperties({
|
||||||
|
model: listingVarientModel,
|
||||||
|
properties,
|
||||||
|
filter,
|
||||||
|
populate: POPULATE_FIELDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing listing varients.');
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of listing varients. Count: ${result.length}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getListingVarientRouteHandler = async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const result = await getObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id,
|
||||||
|
populate: POPULATE_FIELDS,
|
||||||
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
logger.warn(`Listing varient not found with supplied id.`);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.debug(`Retrieved listing varient with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editListingVarientRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
logger.trace(`Listing varient with ID: ${id}`);
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
listing: req.body.listing,
|
||||||
|
product: req.body.product,
|
||||||
|
productSku: req.body.productSku,
|
||||||
|
price: req.body.price,
|
||||||
|
currency: req.body.currency,
|
||||||
|
priceTaxRate: req.body.priceTaxRate,
|
||||||
|
priceWithTax: req.body.priceWithTax,
|
||||||
|
};
|
||||||
|
const result = await editObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id,
|
||||||
|
updateData,
|
||||||
|
user: req.user,
|
||||||
|
populate: POPULATE_FIELDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('Error editing listing varient:', result.error);
|
||||||
|
res.status(result.code).send(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Edited listing varient with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const newListingVarientRouteHandler = async (req, res) => {
|
||||||
|
const newData = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
listing: req.body.listing,
|
||||||
|
product: req.body.product,
|
||||||
|
productSku: req.body.productSku,
|
||||||
|
state: req.body.state || { type: 'draft' },
|
||||||
|
price: req.body.price,
|
||||||
|
currency: req.body.currency,
|
||||||
|
priceTaxRate: req.body.priceTaxRate,
|
||||||
|
priceWithTax: req.body.priceWithTax,
|
||||||
|
};
|
||||||
|
const result = await newObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
newData,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('No listing varient created:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`New listing varient with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteListingVarientRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
logger.trace(`Listing varient with ID: ${id}`);
|
||||||
|
|
||||||
|
const result = await deleteObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
logger.error('No listing varient deleted:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Deleted listing varient with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getListingVarientStatsRouteHandler = async (req, res) => {
|
||||||
|
const result = await getModelStats({ model: listingVarientModel });
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error fetching listing varient stats:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.trace('Listing varient stats:', result);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getListingVarientHistoryRouteHandler = async (req, res) => {
|
||||||
|
const from = req.query.from;
|
||||||
|
const to = req.query.to;
|
||||||
|
const result = await getModelHistory({ model: listingVarientModel, from, to });
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error fetching listing varient history:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
logger.trace('Listing varient history:', result);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VARIENT_POPULATE_MARKETPLACE = [
|
||||||
|
{ path: 'listing', populate: { path: 'marketplace' } },
|
||||||
|
'product',
|
||||||
|
'productSku',
|
||||||
|
'priceTaxRate',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const publishListingVarientRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
const stateOk = await checkStates({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id,
|
||||||
|
states: ['draft', 'inactive'],
|
||||||
|
});
|
||||||
|
if (stateOk?.error) {
|
||||||
|
logger.error('Error checking listing varient state:', stateOk.error);
|
||||||
|
return res.status(stateOk.code).send(stateOk);
|
||||||
|
}
|
||||||
|
if (stateOk === false) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing varient must be in draft or inactive state to publish.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncingCheck = await checkStates({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id,
|
||||||
|
states: ['syncing'],
|
||||||
|
});
|
||||||
|
if (syncingCheck === true) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing varient is syncing; wait for sync to finish before publishing.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await listingVarientModel
|
||||||
|
.findById(id)
|
||||||
|
.populate({
|
||||||
|
path: 'listing',
|
||||||
|
populate: [{ path: 'marketplace' }, { path: 'vendor' }, { path: 'stockLocation' }],
|
||||||
|
})
|
||||||
|
.lean();
|
||||||
|
if (!doc) {
|
||||||
|
return res.status(404).send({ error: 'Listing varient not found.', code: 404 });
|
||||||
|
}
|
||||||
|
if (!doc._reference) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing varient must have a reference (SKU) before publishing.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketplace = doc.listing?.marketplace;
|
||||||
|
const marketplaceId = marketplace?._id || marketplace;
|
||||||
|
if (!marketplaceId) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing has no marketplace; cannot publish offer.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!marketplace?.active) {
|
||||||
|
return res.status(400).send({ error: 'Marketplace is not active.', code: 400 });
|
||||||
|
}
|
||||||
|
if (!hasIntegration(marketplace.provider)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'No integration is configured for this marketplace.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!doc.listing?.stockLocation) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing must have a stock location before publishing.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiResult = await publishMarketplaceOfferForSku(
|
||||||
|
marketplace,
|
||||||
|
req.user,
|
||||||
|
doc._reference,
|
||||||
|
doc.listing
|
||||||
|
);
|
||||||
|
|
||||||
|
await editObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id,
|
||||||
|
updateData: {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
state: { type: 'active' },
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
},
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listingId = doc.listing?._id || doc.listing;
|
||||||
|
if (listingId) {
|
||||||
|
const listingUpdate = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
state: { type: 'active' },
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (apiResult?.listingId) {
|
||||||
|
listingUpdate.url = `https://www.ebay.com/itm/${apiResult.listingId}`;
|
||||||
|
}
|
||||||
|
await editObject({
|
||||||
|
model: listingModel,
|
||||||
|
id: listingId,
|
||||||
|
updateData: listingUpdate,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await getObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id,
|
||||||
|
populate: VARIENT_POPULATE_MARKETPLACE,
|
||||||
|
});
|
||||||
|
res.send(updated);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Publish listing varient failed: ${err.message}`);
|
||||||
|
res.status(500).send({ error: err.message, code: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unpublishListingVarientRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
|
||||||
|
const stateOk = await checkStates({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id,
|
||||||
|
states: ['active'],
|
||||||
|
});
|
||||||
|
if (stateOk?.error) {
|
||||||
|
logger.error('Error checking listing varient state:', stateOk.error);
|
||||||
|
return res.status(stateOk.code).send(stateOk);
|
||||||
|
}
|
||||||
|
if (stateOk === false) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing varient must be in active state to unpublish.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncingCheck = await checkStates({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id,
|
||||||
|
states: ['syncing'],
|
||||||
|
});
|
||||||
|
if (syncingCheck === true) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing varient is syncing; wait for sync to finish before unpublishing.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await listingVarientModel
|
||||||
|
.findById(id)
|
||||||
|
.populate({ path: 'listing', populate: { path: 'marketplace' } })
|
||||||
|
.lean();
|
||||||
|
if (!doc) {
|
||||||
|
return res.status(404).send({ error: 'Listing varient not found.', code: 404 });
|
||||||
|
}
|
||||||
|
if (!doc._reference) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing varient must have a reference (SKU) before unpublishing.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketplace = doc.listing?.marketplace;
|
||||||
|
const marketplaceId = marketplace?._id || marketplace;
|
||||||
|
if (!marketplaceId) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Listing has no marketplace; cannot withdraw offer.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!marketplace?.active) {
|
||||||
|
return res.status(400).send({ error: 'Marketplace is not active.', code: 400 });
|
||||||
|
}
|
||||||
|
if (!hasIntegration(marketplace.provider)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'No integration is configured for this marketplace.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withdrawMarketplaceOfferForSku(marketplace, req.user, doc._reference);
|
||||||
|
|
||||||
|
await editObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id,
|
||||||
|
updateData: {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
state: { type: 'draft' },
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
},
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentListingId = doc.listing?._id || doc.listing;
|
||||||
|
if (parentListingId) {
|
||||||
|
const siblings = await listingVarientModel.find({ listing: parentListingId }).lean();
|
||||||
|
const anyActive = siblings.some(
|
||||||
|
(v) => String(v._id) !== String(id) && v.state?.type === 'active'
|
||||||
|
);
|
||||||
|
if (!anyActive) {
|
||||||
|
await editObject({
|
||||||
|
model: listingModel,
|
||||||
|
id: parentListingId,
|
||||||
|
updateData: {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
state: { type: 'draft' },
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
},
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await getObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id,
|
||||||
|
populate: VARIENT_POPULATE_MARKETPLACE,
|
||||||
|
});
|
||||||
|
res.send(updated);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Unpublish listing varient failed: ${err.message}`);
|
||||||
|
res.status(500).send({ error: err.message, code: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
getModelStats,
|
getModelStats,
|
||||||
getModelHistory,
|
getModelHistory,
|
||||||
} from '../../database/database.js';
|
} from '../../database/database.js';
|
||||||
|
import * as marketplaceIntegration from '../../integrations/marketplaceworker.js';
|
||||||
const logger = log4js.getLogger('Marketplaces');
|
const logger = log4js.getLogger('Marketplaces');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
@ -118,6 +119,8 @@ export const newMarketplaceRouteHandler = async (req, res) => {
|
|||||||
name: req.body.name,
|
name: req.body.name,
|
||||||
provider: req.body.provider,
|
provider: req.body.provider,
|
||||||
active: req.body.active !== false,
|
active: req.body.active !== false,
|
||||||
|
connected: req.body.connected === true,
|
||||||
|
state: req.body.state || { type: req.body.active ? 'disconnected' : 'inactive' },
|
||||||
config: req.body.config || {},
|
config: req.body.config || {},
|
||||||
};
|
};
|
||||||
const result = await newObject({
|
const result = await newObject({
|
||||||
@ -174,3 +177,189 @@ export const getMarketplaceHistoryRouteHandler = async (req, res) => {
|
|||||||
logger.trace('Marketplace history:', result);
|
logger.trace('Marketplace history:', result);
|
||||||
res.send(result);
|
res.send(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMarketplaceAuthUrlRouteHandler = async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const marketplace = await getObject({ model: marketplaceModel, id });
|
||||||
|
if (marketplace?.error) {
|
||||||
|
logger.warn('Marketplace not found for authorization URL request.');
|
||||||
|
return res.status(marketplace.code).send(marketplace);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!marketplaceIntegration.hasIntegration(marketplace.provider)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: `No integration available for provider: ${marketplace.provider}`,
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!marketplaceIntegration.canAuthorize(marketplace)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: `Provider ${marketplace.provider} does not support marketplace authorization.`,
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = marketplaceIntegration.getAuthorizationUrl(marketplace, {
|
||||||
|
state: req.query.state,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send({ success: true, url });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error generating marketplace authorization URL:', err.message);
|
||||||
|
res.status(400).send({ error: err.message, code: 400 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exchangeMarketplaceAuthCodeRouteHandler = async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const { code, state } = req.body;
|
||||||
|
|
||||||
|
const marketplace = await getObject({ model: marketplaceModel, id });
|
||||||
|
if (marketplace?.error) {
|
||||||
|
logger.warn('Marketplace not found for authorization exchange.');
|
||||||
|
return res.status(marketplace.code).send(marketplace);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!marketplaceIntegration.hasIntegration(marketplace.provider)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: `No integration available for provider: ${marketplace.provider}`,
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await marketplaceIntegration.exchangeAuthorizationCode(marketplace, req.user, {
|
||||||
|
code,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Marketplace authorization completed for ${marketplace.name}`);
|
||||||
|
res.send({ success: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error exchanging marketplace authorization code:', err.message);
|
||||||
|
res.status(400).send({ error: err.message, code: 400 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const refreshMarketplaceAuthRouteHandler = async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const marketplace = await getObject({ model: marketplaceModel, id });
|
||||||
|
if (marketplace?.error) {
|
||||||
|
logger.warn('Marketplace not found for token refresh.');
|
||||||
|
return res.status(marketplace.code).send(marketplace);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!marketplaceIntegration.hasIntegration(marketplace.provider)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: `No integration available for provider: ${marketplace.provider}`,
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await marketplaceIntegration.refreshMarketplaceAuth(marketplace, req.user);
|
||||||
|
logger.info(`Marketplace token refreshed for ${marketplace.name}`);
|
||||||
|
res.send({ success: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error refreshing marketplace token:', err.message);
|
||||||
|
res.status(400).send({ error: err.message, code: 400 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncMarketplaceItemsRouteHandler = async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
|
||||||
|
const marketplace = await getObject({ model: marketplaceModel, id });
|
||||||
|
if (marketplace?.error) {
|
||||||
|
logger.warn('Marketplace not found for sync.');
|
||||||
|
return res.status(marketplace.code).send(marketplace);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!marketplace.active) {
|
||||||
|
return res.status(400).send({ error: 'Marketplace is not active.', code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!marketplaceIntegration.hasIntegration(marketplace.provider)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: `No integration available for provider: ${marketplace.provider}`,
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
marketplaceIntegration.syncItems(marketplace, req.user);
|
||||||
|
logger.info(`Item sync initiated in background for marketplace ${marketplace.name}`);
|
||||||
|
res.send({ success: true, message: 'Item sync started' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncMarketplaceOrdersRouteHandler = async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const { startTime, endTime } = req.query;
|
||||||
|
|
||||||
|
const marketplace = await getObject({ model: marketplaceModel, id });
|
||||||
|
if (marketplace?.error) {
|
||||||
|
logger.warn('Marketplace not found for order sync.');
|
||||||
|
return res.status(marketplace.code).send(marketplace);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!marketplace.active) {
|
||||||
|
return res.status(400).send({ error: 'Marketplace is not active.', code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!marketplaceIntegration.hasIntegration(marketplace.provider)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: `No integration available for provider: ${marketplace.provider}`,
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
marketplaceIntegration.syncOrders(marketplace, req.user, {
|
||||||
|
startTime: startTime ? parseInt(startTime) : undefined,
|
||||||
|
endTime: endTime ? parseInt(endTime) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Order sync initiated in background for marketplace ${marketplace.name}`);
|
||||||
|
res.send({ success: true, message: 'Order sync started' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const marketplaceWebhookRouteHandler = async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
|
||||||
|
const marketplace = await getObject({ model: marketplaceModel, id });
|
||||||
|
if (marketplace?.error) {
|
||||||
|
logger.warn('Marketplace not found for webhook.');
|
||||||
|
return res.status(404).send({ error: 'Marketplace not found.', code: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!marketplaceIntegration.hasIntegration(marketplace.provider)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: `No integration available for provider: ${marketplace.provider}`,
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature =
|
||||||
|
req.headers['x-tts-signature'] ||
|
||||||
|
req.headers['x-ebay-signature'] ||
|
||||||
|
req.headers['x-signature'] ||
|
||||||
|
'';
|
||||||
|
const rawBody = JSON.stringify(req.body);
|
||||||
|
|
||||||
|
if (signature && marketplaceIntegration.canVerifyWebhookSignature(marketplace)) {
|
||||||
|
const valid = marketplaceIntegration.verifyWebhookSignature(marketplace, rawBody, signature);
|
||||||
|
if (!valid) {
|
||||||
|
logger.warn(`Invalid webhook signature for marketplace ${marketplace.name}`);
|
||||||
|
return res.status(401).send({ error: 'Invalid signature.', code: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await marketplaceIntegration.handleWebhook(marketplace, req.body);
|
||||||
|
logger.info(`Webhook processed for marketplace ${marketplace.name}: ${result.action}`);
|
||||||
|
res.send({ success: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error processing marketplace webhook:', err.message);
|
||||||
|
res.status(500).send({ error: err.message, code: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -30,6 +30,11 @@ function buildWildcardRegexPattern(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseFilter(property, value) {
|
function parseFilter(property, value) {
|
||||||
|
// Normalize state filter to state.type for schemas with state: { type }
|
||||||
|
if (property === 'state') {
|
||||||
|
property = 'state.type';
|
||||||
|
}
|
||||||
|
|
||||||
if (value?._id !== undefined && value?._id !== null) {
|
if (value?._id !== undefined && value?._id !== null) {
|
||||||
return { [property]: { _id: new mongoose.Types.ObjectId(value._id) } };
|
return { [property]: { _id: new mongoose.Types.ObjectId(value._id) } };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user