| Current Path : /proc/thread-self/root/home/deltalab/PMS/partner-manager-backend/services/ |
| Current File : //proc/thread-self/root/home/deltalab/PMS/partner-manager-backend/services/sms.js |
/* eslint-disable no-param-reassign */
/* eslint-disable prefer-destructuring */
/* eslint-disable no-continue */
/* eslint-disable new-cap */
/* eslint-disable no-await-in-loop */
/* eslint-disable no-use-before-define */
/* eslint-disable max-len */
/* eslint-disable no-restricted-syntax */
/**
* Shipment Management Service
*
* Fetches shipments from SMS Connector
* and create/update the local copies to be served by mongoose.
* See models/mongoose/shipment.js for PMS-FE queries.
*/
// DEPENDENCIES ===========================================
const { dotenv } = require('dotenv').config();
const { GraphQLClient, gql } = require('graphql-request');
const ims = require('./ims');
const { countryCodes } = require('./utils/countrycodes');
const mail = require('./mail');
const utility = require('./utility');
// RESOURCES ==============================================
const { createShipmentInfo } = require('../models/mongoose/shipment');
const { createShipmentValueInfo } = require('../models/mongoose/shipment');
const { shipmentModel } = require('../models/mongoose/shipment');
const { warehouseModel } = require('../models/mongoose/warehouse');
const { channelModel } = require('../models/mongoose/channel');
const { addressModel } = require('../models/mongoose/address');
const { bookPickupModel } = require('../models/mongoose/pickup');
const { shipmentBaseInfoModel } = require('../models/mongoose/shipment');
const { parcelBaseModel } = require('../models/mongoose/parcel');
const { parcelModel } = require('../models/mongoose/parcel');
const { boxModel } = require('../models/mongoose/parcel');
const { productModel } = require('../models/mongoose/product');
const { carrierModel } = require('../models/mongoose/carrier');
const { warehouseJournalModel } = require('../models/mongoose/warehouse-journal');
// INITIALIZATION =========================================
// SMS connection initialization
const smsUrl = process.env.SMS_CONN_URL;
const smsClient = new GraphQLClient(smsUrl);
// UTILITIES =============================================
async function isRefrigerated(order) {
for (const item of order.items) {
const product = await productModel.findOne({ sku: item.sku });
if (product) {
console.log(`product found, is it refrigerated? ${product.refrigerated}`);
}
if (product && product.refrigerated) {
return true;
}
}
return false;
}
function isLocalOrder(order) {
console.log(`Da dove proviene questo ordine? ${order.shippingAddress.province}`);
return order.shippingAddress.province === 'Trento' || order.shippingAddress.province === 'TN';
}
// PUBLIC FUNCTIONS ======================================
/**
* Start the order shipping process
*
* @param {OrderSchema} order what to dispatch and to where
* @param {Address} fromaddress the warehouse address where the order will be shipped from
* @return the backend shipment id to be used for future references
*/
async function checkShipment(order, instanceId, storeviewCode, action, activities) {
// check if activities are specified
let isActivityBound = false;
if (activities) {
isActivityBound = true;
}
console.log(`Checking shipment for order ${order.name}`);
// 1) select shipment warehouse by channel
console.log('Fase 2: Selezione magazzino');
const warehouses = await getChannelShipmentWarehouses(order, instanceId, storeviewCode);
if (!warehouses) {
throw new Error(`Cannot ship ${order.name}: no shipment warehouses found for the involved channel ${instanceId} ${storeviewCode}`);
}
// 2) check if it already contains all items (otherwise create transfers)
const checkedWarehouses = await checkOrderAvailability(order, warehouses, instanceId, storeviewCode, activities);
if (checkedWarehouses.okWarehouses.length === 0) {
// TODO: choose the shipment warehouse by relevant criteria
console.log('creo i trasferimenti per gli ordini');
if (!order.shipmentWarehouse) {
order.shipmentWarehouse = checkedWarehouses.invalidWarehouses[0];
await utility.updateActivity(order, utility.ACTIVITY_WAREHOUSE, true);
}
await order.save();
console.log('Fase 3: Impegno quantità');
console.log('Fase 4: Creazione trasferimenti');
await createWarehouseBookingsAndTransfers(order, order.shipmentWarehouse ? order.shipmentWarehouse : checkedWarehouses.invalidWarehouses[0], instanceId, storeviewCode, action, activities);
throw new Error(`cannot create shipment for order ${order.name}: no warehouse contains all the items required. Created transfers.`);
}
// if order.shipmentWarehouse is specified, this order has already been processed and a suitable warehouse was already specified
const bestWarehouseAddress = await getBestWarehouseAddress(order, order.shipmentWarehouse ? [order.shipmentWarehouse] : checkedWarehouses.okWarehouses);
if (!bestWarehouseAddress.from) {
throw new Error(`Cannot ship ${order.name}: no valid warehouse address found`);
}
if (!order.shipmentWarehouse) {
order.shipmentWarehouse = checkedWarehouses.invalidWarehouses[0];
await utility.updateActivity(order, utility.ACTIVITY_WAREHOUSE, true);
await order.save();
}
await utility.updateActivity(order, utility.ACTIVITY_WAREHOUSE, true);
//TODO: controllare funzionamento per evitare doppio trasferimento
if ((!isActivityBound && action === utility.ACTION_SURF) || (isActivityBound && action === utility.ACTION_SURF && activities.booking)) {
if (isActivityBound && !utility.checkActivity(order, utility.ACTIVITY_BOOKING)) {
throw new Error(`There is an error in the ${utility.ACTIVITY_BOOKING} step. Check prerequisites.`);
}
// perform booking, now it's needed for each order (even though I could have to ship it immediately)
await createWarehouseBookingsAndTransfers(order, bestWarehouseAddress.warehouse, instanceId, storeviewCode, action, activities);
await utility.updateActivity(order, utility.ACTIVITY_BOOKING, true);
}
console.log(`shipping origin address is ${bestWarehouseAddress.from}`);
let parcels = null;
if (!isActivityBound || (isActivityBound && activities.pickingList)) {
console.log('Fase 5: Creazione picking list');
if (isActivityBound && !utility.checkActivity(order, utility.ACTIVITY_PICKING_LIST)) {
throw new Error(`There is an error in the ${utility.ACTIVITY_PICKING_LIST} step. Check prerequisites.`);
}
parcels = await createParcels(order);
await utility.updateActivity(order, utility.ACTIVITY_PICKING_LIST, true);
}
// Explicitly create the shipment
// const orderId = order.name.substring(1); // CUT # FOR SHOPIFY ORDER
let shipmentCreationData = null;
if (!isActivityBound || (isActivityBound && activities.shipmentShippyPro)) {
if (isActivityBound && !utility.checkActivity(order, utility.ACTIVITY_SHIPMENT_SHIPPYPRO)) {
throw new Error(`There is an error in the ${utility.ACTIVITY_SHIPMENT_SHIPPYPRO} step. Check prerequisites.`);
}
// Prepare the shipment basic info using the order as input
if (!parcels) {
//if parcels is not created (i.e. picking list was executed in the past)
parcels = await createParcels(order);
}
const shipmentBaseInfo = new shipmentBaseInfoModel();
shipmentBaseInfo.from = bestWarehouseAddress.from;
shipmentBaseInfo.from.email = bestWarehouseAddress.warehouse.email[0]; // AG1263
shipmentBaseInfo.to = getShippingAddress(order);
shipmentBaseInfo.parcels = parcels;
shipmentBaseInfo.shipmentValueInfo = createShipmentValueInfo(
order.totalPriceSet.amount,
order.totalPriceSet.currencyCode,
order.cashOnDelivery,
);
// Retrieve the available rates
const rates = await fetchRates(shipmentBaseInfo);
const refrigeratedOrder = await isRefrigerated(order);
const localOrder = isLocalOrder(order);
const carrierIds = await getCarrierIds(order.partnerId, refrigeratedOrder, localOrder);
const rate = getBestRate(rates, carrierIds);
if (!rate) {
console.log(`no rate found for order ${order.name}`);
throw new Error(`cannot create shipment for order ${order.name}: no valid rate found`);
}
console.log('Fase 6: creazione lettera di vettura');
const orderId = order.name;
shipmentCreationData = await createShipment(orderId, rate.carrier.smsid, rate.rateId, shipmentBaseInfo);
await utility.updateActivity(order, utility.ACTIVITY_SHIPMENT_SHIPPYPRO, true);
console.log(`[SHIPMENT-SHIPPYPRO] shipment created for order ${order.name}`);
console.log(`[SHIPMENT-SHIPPYPRO] shipment creation data ${JSON.stringify(shipmentCreationData)}`);
}
if (isActivityBound && activities.shipmentSurf) {
// surf is not used in default behaviour
if (isActivityBound && !utility.checkActivity(order, utility.ACTIVITY_SHIPMENT_SURF)) {
throw new Error(`There is an error in the ${utility.ACTIVITY_SHIPMENT_SURF} step. Check prerequisites.`);
}
console.log('Fase 6: creazione lettera di vettura SURF');
await utility.updateActivity(order, utility.ACTIVITY_SHIPMENT_SURF, true);
console.log(`[SHIPMENT-SURF] shipment created for order ${order.name}`);
console.log(`[SHIPMENT-SURF] shipment creation data ${JSON.stringify(shipmentCreationData)}`);
}
// DEBUG -----------------------
// Book a pickup for this shipment
const pickupDate = new Date(); // Today
pickupDate.setDate(getPickupDate(pickupDate).getDate()); // Next working day
// console.log(pickupDate);
// // forcing pickup date to today
// const customPickupDate = new Date();
// customPickupDate.setHours(15);
// customPickupDate.setMinutes(30);
// console.log(customPickupDate);
// const pickupConfirmation = await sms.bookPickup(customRate.carrier.smsid, shipmentBaseInfo, customPickupDate);
if (!isActivityBound || (isActivityBound && activities.mail)) {
if (isActivityBound && !utility.checkActivity(order, utility.ACTIVITY_MAIL)) {
throw new Error(`There is an error in the ${utility.ACTIVITY_MAIL} step. Check prerequisites.`);
}
console.log('Fase 7: invio mail');
console.log(`sending mail to ${bestWarehouseAddress.warehouse.email}`);
if (!parcels) {
//if parcels is not created (i.e. picking list was executed in the past)
parcels = await createParcels(order);
}
// use warehouseAddress.from
const emails = bestWarehouseAddress.warehouse.email;
let emailAddresses = '';
emails.forEach((email) => {
emailAddresses += email;
emailAddresses += ';';
});
mail.sendPickingEmail(emailAddresses, order, parcels, shipmentCreationData ? shipmentCreationData.labelUrl : [], pickupDate);
await utility.updateActivity(order, utility.ACTIVITY_MAIL, true);
}
let shipment = null;
if (!isActivityBound || (isActivityBound && activities.picking)) {
if (isActivityBound && !utility.checkActivity(order, utility.ACTIVITY_PICKING)) {
throw new Error(`There is an error in the ${utility.ACTIVITY_PICKING} step. Check prerequisites.`);
}
shipment = new shipmentModel();
// const orderIds = shipmentCreationData.shipmentId; // not managing multiple orders in one shipment at the moment, otherwise format is XXX;YYY;ZZZ;
// once order is confirmed, subtract order quantities from bookedQuantity and InventoryLevel
console.log('Fase 8: pick quantità');
await pickQuantities(order, bestWarehouseAddress);
if (shipmentCreationData) {
// transform the shipment id number to a string
const shipmentId = shipmentCreationData.shipmentId.toString();
// Create the shipping manifest
await createManifest(shipmentId);
// DEBUG -----------------------
console.log(`manifest created for shipment ${order.name}`);
// Persist the shipment locally
shipment.smsid = shipmentCreationData.shipmentId;
shipment.labelUrl = shipmentCreationData.labelUrl;
shipment.orderId = order._id;
shipment.trackingCarrier = shipmentCreationData.trackingCarrier;
shipment.trackingNumber = shipmentCreationData.trackingNumber;
// shipment.pickupId = pickupId; //not provided yet, since pickup is not created from PMS
shipment.partnerId = order.partnerId;
shipment.createdAt = new Date();
await shipment.save((err) => {
if (err) {
throw new Error(`Cannot save shipment for order ${order.name}: ${err}`);
}
});
console.log(`shipment ${shipment._id} created successfully for order ${order.name}`);
}
console.log('saving all products involved');
await utility.updateActivity(order, utility.ACTIVITY_PICKING, true);
}
await saveReferences(order);
if (!isActivityBound || (isActivityBound && activities.close)) {
if (isActivityBound && !utility.checkActivity(order, utility.ACTIVITY_CLOSE)) {
throw new Error(`There is an error in the ${utility.ACTIVITY_CLOSE} step. Check prerequisites.`);
}
await utility.updateActivity(order, utility.ACTIVITY_CLOSE, true);
return shipment._id;
}
}
// EXPORTS =================================================
module.exports = {
checkShipment,
getBestWarehouseAddress,
findBestFittingBox,
sortBoxes,
sortItems,
estimateParcels,
createParcels,
fetchRates,
getBestRate,
getShippingAddress,
createShipmentValueInfo,
createShipment,
bookPickup,
getCarrierIds,
getPickupDate,
pickQuantities,
};
// PRIVATE FUNCTIONS =======================================
/**
* Find the best warehouse for the order shipment
*
* @param {*} order the order to ship
* @param {*} warehouses a list of valid warehouses
*/
async function getBestWarehouseAddress(order, warehouses) {
const bestRates = [];
// Prepare the shipment basic info used to fetch rates
const shipmentBaseInfo = new shipmentBaseInfoModel();
shipmentBaseInfo.to = getShippingAddress(order);
shipmentBaseInfo.parcels = await createParcels(order);
shipmentBaseInfo.shipmentValueInfo = createShipmentValueInfo(
order.totalPriceSet.amount,
order.totalPriceSet.currencyCode,
order.cashOnDelivery,
);
const refrigeratedOrder = await isRefrigerated(order);
const localOrder = isLocalOrder(order);
const carrierIds = await getCarrierIds(order.partnerId, refrigeratedOrder, localOrder);
console.log(carrierIds);
// Compute the rate for all the warehouses
for (const warehouse of warehouses) {
// Update the shipment origin
shipmentBaseInfo.from = getShipmentWarehouseAddress(warehouse.address);
// Retrieve the available rates for the shipment
const rates = await fetchRates(shipmentBaseInfo);
// Get the best rate out of the given list of available carriers
const rate = getBestRate(rates, carrierIds);
if (!rate) {
console.log(`no rate found for order ${order.name} shipping from ${warehouse.address}`);
} else {
// Add original warehouse to the rate
rate.warehouse = warehouse;
// Add the warehouse address
rate.from = shipmentBaseInfo.from;
bestRates.push(rate);
}
}
// Get the best rate among all the best rates
const bestRate = getBestRate(bestRates, carrierIds);
// Sanity check
if (!bestRate) return null;
if (!order.shipmentWarehouse) {
// save bestRate warehouse in order if not already
order.shipmentWarehouse = bestRate.warehouses;
await order.save();
}
// Return the associated warehouse address
return bestRate;
}
/**
* Retrieve the matching product for the given order item
* @param {orderItemModel} orderItem
* @return the matching productModel or null if no product matches the given item
*/
async function getValidProduct(orderItem, partnerId) {
let product;
if (orderItem.sku) {
// Look for product based on item imsgid and order partnerId
product = await productModel.findOne({ 'offers.sku': orderItem.sku }); // controllare se il prodotto va bene
} else {
// Look for product based on item sku, name and order partnerId
product = await productModel.findOne({ 'offers.sku': orderItem.sku, name: orderItem.name }); // controllare se il prodotto va bene
}
// Sanity check
if (!product) {
console.log(`no such product for sku ${orderItem.sku} (partner ${partnerId})`);
return null;
}
// Do not look for inventory levels for non-phisical products, if we are shipping the product
// Return product data in availability calculations
if (!product.requiresShipping) {
console.log(`product ${product.sku} doesn't require shipping`);
return null;
}
return product;
}
async function getChannelShipmentWarehouses(order, instanceId, storeviewCode) {
console.log('cerco se ci sono magazzini adatti per la spedizione');
const orderChannel = await channelModel.findOne({ instanceId, storeName: storeviewCode });
if (orderChannel) {
const warehouses = await warehouseModel.find({ channelAssignments: { $elemMatch: { channelId: orderChannel._id, shipmentWarehouse: true } } });
if (warehouses) {
console.log(`ce ne sono ${warehouses.length}`);
return warehouses;
}
}
return [];
}
async function checkOrderAvailability(order, warehouses, instanceId, storeviewCode, activities) {
// for each warehouse, check if one has all the required items
// if not, loop through all the relevant warehouses of the product and create transfer orders to the warehouse for the difference (if any);
const okWarehouses = [];
const invalidWarehouses = [];
for (const warehouse of warehouses) {
let validOrder = true;
for (const orderItem of order.items) {
const reference = await getValidProduct(orderItem, order.partnerId);
for (const offer of reference.offers) {
const orderChannel = await channelModel.findOne({ instanceId, storeName: storeviewCode });
if (!offer.channelId.equals(orderChannel._id)) {
console.log('non è una offerta di questo canale');
continue;
}
console.log(`extracting ${orderItem.name}`);
console.log(`is product null? ${reference === null}`);
// In case no such product has to be shipped, skip this item
if (!reference) {
console.log('Il prodotto non esiste');
continue;
}
// same thing if it's not to be shipped
if (!reference.requiresShipping) {
console.log('Il prodotto non deve essere spedito');
continue;
}
// same thing if it's not tracked in the inventory
if (!reference.trackInventory) {
console.log('Il prodotto non traccia la giacenza');
continue;
}
// Check the product inventory levels
// TODO: search for quantity in the warehouse;
console.log('Verifico se il prodotto è presente in un magazzino nella quantità richiesta');
let warehouseFound = false;
for (const inventoryLevel of reference.inventoryLevels) {
if (inventoryLevel.warehouseId.equals(warehouse._id)) {
console.log(`il magazzino ${warehouse.name} contiene il prodotto`);
warehouseFound = true;
let bookedQuantity = 0;
for (let i = 0; i < inventoryLevel.bookings.length; i++) {
const booking = inventoryLevel.bookings[i];
if (booking.omsgid === order.omsgid && !booking.fulfilled) {
bookedQuantity += booking.amount;
}
}
const remainingQuantity = orderItem.quantity - bookedQuantity;
if (inventoryLevel.amount < remainingQuantity && !reference.sellBelowZero) {
console.log('...ma non in quantità sufficiente');
// if warehouse does not contain enough stock, it's not eligible for shipping at the moment
validOrder = false;
break;
}
}
}
if (!warehouseFound) {
console.log(`il magazzino ${warehouse.name} non contiene il prodotto`);
// an item does not have stock in eligible warehouses, the whole order must fail for now
validOrder = false;
}
}
}
if (validOrder) {
console.log('Il magazzino ha stock disponibile');
okWarehouses.push(warehouse);
} else {
console.log('Il magazzino è nella lista degli indisponibili');
invalidWarehouses.push(warehouse);
}
}
return { okWarehouses, invalidWarehouses };
}
async function createWarehouseBookingsAndTransfers(order, warehouse, instanceId, storeviewCode, action, activities) {
let isActivityBound = false;
if (activities) {
isActivityBound = true;
}
const requestBody = {
omsgid: order.omsgid,
confirmed: false,
};
const orderPendingTransfers = await warehouseJournalModel.find(requestBody);
if (orderPendingTransfers.length > 0) {
console.log('there are some transfers yet to be confirmed. Check them first');
return;
}
for (const orderItem of order.items) {
const reference = await getValidProduct(orderItem, order.partnerId);
// Check the product inventory levels
let requiredQuantity = orderItem.quantity;
console.log(`starting loop with ${requiredQuantity} pieces`);
for (const offer of reference.offers) {
const orderChannel = await channelModel.findOne({ instanceId, storeName: storeviewCode });
console.log(`extracting ${orderItem.name}`);
if (requiredQuantity <= 0) {
console.log('all available quantity picked, exiting');
break;
}
if (!reference) {
console.log('no reference');
continue;
}
// same thing if it's not to be shipped
if (!reference.requiresShipping) {
console.log('no shipping needed');
continue;
}
// same thing if it's not tracked in the inventory
if (!reference.trackInventory) {
console.log('no tracked inventory');
continue;
}
const offerQuantity = await getOfferQuantity(reference, offer, order.omsgid);
if (offerQuantity < orderItem.quantity && !reference.sellBelowZero) {
console.log('the entire stock of this product is not enough to fulfill the order');
// if warehouse does not contain enough stock, it's not eligible for shipping at the moment
break;
}
// first loop through inventoryLevels in order to find the quantity already in the warehouse
for (const inventoryLevel of reference.inventoryLevels) {
console.log(`looking for ${requiredQuantity} pieces in warehouses`);
if (requiredQuantity <= 0) {
break;
}
if (!reference.sellBelowZero && inventoryLevel.amount <= 0) {
// no stock, no transfer
console.log('zero stock, no available transfers');
continue;
}
if (inventoryLevel.warehouseId.equals(warehouse._id) && inventoryLevel.amount > 0) {
console.log(`same warehouse, no need to transfer ${inventoryLevel.amount} quantity since it's already there`);
if (!inventoryLevel.bookings) {
inventoryLevel.bookings = [];
}
if (inventoryLevel.amount >= requiredQuantity) {
inventoryLevel.bookings.push({ amount: requiredQuantity, omsgid: order.omsgid, channelId: orderChannel._id });
inventoryLevel.amount -= requiredQuantity;
await utility.updateActivity(order, utility.ACTIVITY_BOOKING, true);
requiredQuantity = 0;
} else {
inventoryLevel.bookings.push({ amount: inventoryLevel.amount, omsgid: order.omsgid, channelId: orderChannel._id });
requiredQuantity -= inventoryLevel.amount;
inventoryLevel.amount = 0;
}
await reference.save();
await ims.referenceUpdate(reference, reference.sku);
break;
}
}
for (const inventoryLevel of reference.inventoryLevels) {
if (requiredQuantity <= 0) {
break;
}
if (!reference.sellBelowZero && inventoryLevel.amount <= 0) {
// no stock, no transfer
console.log('zero stock, no available transfers');
continue;
}
// check if warehouse is eligible on channel
if (!isChannelWarehouse(inventoryLevel, orderChannel)) {
continue;
}
console.log(inventoryLevel.warehouseId);
if (inventoryLevel.warehouseId.equals(warehouse._id)) {
console.log('same warehouse');
// don't consider the same warehouse in the loop
continue;
}
const externalWarehouse = reference.partnerId !== warehouse.partnerId.toString();
console.log('there is enough stock to fulfill this order');
const warehouseFrom = await warehouseModel.findById(inventoryLevel.warehouseId);
if (inventoryLevel.amount >= requiredQuantity || reference.sellBelowZero) {
console.log('################################################## LEVEL ENOUGH #############################################################');
console.log('... and there is enough in this warehouse');
inventoryLevel.amount -= requiredQuantity;
ims.addEntryToWarehouseJournal(inventoryLevel.warehouseId, reference._id, reference.partnerId, requiredQuantity, requiredQuantity, 'DECREASE', reference.refrigerated, !externalWarehouse, false, order.omsgid);
// TODO: ACTIVITY (journal non necessita di conferma se activity.transfer == FALSE) AND ADD BOOKING nel magazzino di destinazione
if (isActivityBound && action === utility.ACTION_SURF) {
if (isActivityBound && !utility.checkActivity(order, utility.ACTIVITY_BOOKING)) {
throw new Error(`There is an error in the ${utility.ACTIVITY_BOOKING} step. Check prerequisites.`);
}
console.log('No transfer needed, just confirm them right away');
ims.addEntryToWarehouseJournal(warehouse, reference._id, reference.partnerId, requiredQuantity, requiredQuantity, 'DUMMY', reference.refrigerated, !externalWarehouse, true, order.omsgid, inventoryLevel.warehouseId);
ims.createReferenceBooking(reference._id, warehouse._id, requiredQuantity, order.omsgid);
await utility.updateActivity(order, utility.ACTIVITY_BOOKING, true);
} else {
console.log('Normal behaviour, insert a transfer to be confirmed later');
ims.addEntryToWarehouseJournal(warehouse, reference._id, reference.partnerId, requiredQuantity, requiredQuantity, 'INCREASE', reference.refrigerated, !externalWarehouse, true, order.omsgid, inventoryLevel.warehouseId);
mail.sendWarehouseTransferEmail(warehouseFrom, warehouse, offer, requiredQuantity);
await utility.updateActivity(order, utility.ACTIVITY_TRANSFER, true);
}
await reference.save();
requiredQuantity = 0;
await ims.referenceUpdate(reference, reference.sku);
break;
} else {
console.log('################################################## LEVEL NOT ENOUGH #############################################################');
console.log('...and there is some here');
requiredQuantity -= inventoryLevel.amount;
mail.sendWarehouseTransferEmail(warehouseFrom, warehouse, offer, inventoryLevel.amount);
console.log(`${requiredQuantity} remaining`);
ims.addEntryToWarehouseJournal(inventoryLevel.warehouseId, reference._id, reference.partnerId, inventoryLevel.amount, 0, 'DECREASE', reference.refrigerated, !externalWarehouse, false, order.omsgid);
// ACTIVITY (journal non necessita di conferma se activity.transfer == FALSE) AND ADD BOOKING nel magazzino di destinazione
if (isActivityBound && action === utility.ACTION_SURF) {
if (isActivityBound && !utility.checkActivity(order, utility.ACTIVITY_BOOKING)) {
throw new Error(`There is an error in the ${utility.ACTIVITY_BOOKING} step. Check prerequisites.`);
}
console.log('No transfer needed, just confirm them right away');
ims.addEntryToWarehouseJournal(warehouse, reference._id, reference.partnerId, inventoryLevel.amount, inventoryLevel.amount, 'DUMMY', reference.refrigerated, !externalWarehouse, true, order.omsgid, inventoryLevel.warehouseId);
ims.createReferenceBooking(reference._id, warehouse._id, inventoryLevel.amount, order.omsgid);
await utility.updateActivity(order, utility.ACTIVITY_BOOKING, true);
} else {
console.log('Normal behaviour, insert a transfer to be confirmed later');
ims.addEntryToWarehouseJournal(warehouse, reference._id, reference.partnerId, inventoryLevel.amount, inventoryLevel.amount, 'INCREASE', reference.refrigerated, !externalWarehouse, true, order.omsgid, inventoryLevel.warehouseId);
await utility.updateActivity(order, utility.ACTIVITY_BOOKING, true);
await utility.updateActivity(order, utility.ACTIVITY_TRANSFER, true);
}
inventoryLevel.amount = 0;
// TODO: Manca la mail per la quantità corretta
await reference.save();
await ims.referenceUpdate(reference, reference.sku);
}
// split stock
}
}
}
}
async function isChannelWarehouse(inventoryLevel, orderChannel) {
const inventoryWarehouse = await warehouseModel.findById(inventoryLevel.warehouseId);
for (const channelAssignment of inventoryWarehouse.channelAssignments) {
if (channelAssignment.channelId.equals(orderChannel)) {
return true;
}
}
return false;
}
async function getOfferQuantity(orderItem, offer, omsgid = undefined) {
const channel = await channelModel.findById(offer.channelId);
return ims.getQuantityByChannel(orderItem, channel, omsgid);
}
async function isValidWarehouse(inventoryLevel, instanceId) {
const warehouse = await warehouseModel.findById(inventoryLevel.warehouseId);
const channel = await channelModel.findOne({ instanceId });
if (warehouse) {
for (let j = 0; j < warehouse.channelAssignments.length; j++) {
if (warehouse.channelAssignments[j].channelId.equals(channel._id)) {
return true;
}
}
}
return false;
}
/**
* Adapt the warehouse address to a shipping valid address format
* @param {*} warehouseAddress
*/
function getShipmentWarehouseAddress(warehouseAddress) {
const smsAddress = new addressModel();
smsAddress.name = warehouseAddress.name;
smsAddress.company = warehouseAddress.company;
smsAddress.street1 = warehouseAddress.street1;
smsAddress.street2 = warehouseAddress.street2;
smsAddress.city = warehouseAddress.city;
smsAddress.zip = warehouseAddress.zip;
smsAddress.state = warehouseAddress.country;
smsAddress.country = warehouseAddress.country.length === 2 ? warehouseAddress.country : countryCodes[warehouseAddress.country];
smsAddress.phone = warehouseAddress.phone;
smsAddress.email = warehouseAddress.email;
return smsAddress;
}
/**
* Extract a suitable shipping address for the given order
* @param {orderModel} order
* @returns the shipping address for this order properly configured
*/
function getShippingAddress(order) {
console.log(`shipping address for order ${order.name} is ${order.shippingAddress.name}`);
const smsAddress = new addressModel();
smsAddress.name = order.shippingAddress.name;
smsAddress.company = order.shippingAddress.company ? order.shippingAddress.company : order.shippingAddress.name;
smsAddress.street1 = order.shippingAddress.address1;
smsAddress.street2 = order.shippingAddress.address2;
smsAddress.city = order.shippingAddress.city;
smsAddress.zip = order.shippingAddress.zip;
smsAddress.state = order.shippingAddress.province ?? order.shippingAddress.country;
console.log(`STATE IS ${smsAddress.state}`);
smsAddress.phone = order.shippingAddress.phone;
smsAddress.country = order.shippingAddress.country.length === 2 ? order.shippingAddress.country : countryCodes[order.shippingAddress.country]; // country to ISO
if (order.customer) {
// smsAddress.phone = order.customer.phone;
smsAddress.email = order.customer.email;
} else {
// smsAddress.phone = "";
smsAddress.email = '';
}
// TODO: ensure phone is not empty as DHL doesnt like it
return smsAddress;
}
/**
* Creates a boxModel with the given parameters
* @param {*} name
* @param {*} height
* @param {*} length
* @param {*} width
* @param {*} maxWeight grams
* @param {*} weight grams of the box itself
* @returns a boxModel
*/
function prepareBox(name, height, length, width, maxWeight, weight) {
const box = new boxModel();
box.name = name;
box.height = height;
box.length = length;
box.width = width;
box.maxWeight = maxWeight; // grams
box.weight = weight; // box intrinsic weight
return box;
}
/**
* Create the parcels for the given order.
*
* Parcels are loosely based on the concept of order item
* summarizing size, weight of the objects to be shipped
*
* @param {Order} order
* @return {[Parcel]} parcels
*/
async function createParcels(order) {
const boxes = [];
// FIXME: hardcoded boxes as instructed
boxes.push(prepareBox('monosized box', 800, 700, 500, 12000, 150));
// retrieve the items to be shipped
const items = await getIndividualItems(order);
// perform a best estimate the number and type of parcels to prepare for the order items
const estimatedParcels = estimateParcels(items, boxes);
// upcast the parcel objects to their base model to fit in the expected shipment model
console.log(`estimated ${estimatedParcels.length} parcels for order ${order.name}`);
const parcels = [];
let i = 0;
for (const estimatedParcel of estimatedParcels) {
++i;
console.log(`${i}) parcel ${estimatedParcel.box.name} ${estimatedParcel.box.maxWeight}g with ${estimatedParcel.items.length} items for a total of ${estimatedParcel.weight}g`);
// upcast to the base version, compatible with shipmentinfo
const parcel = new parcelBaseModel(estimatedParcel);
parcels.push(parcel);
}
return parcels;
}
/**
* See if the given item can fit in any of the specified parcels (First Fit algorithm)
* @param {[parcelModel]} parcels
* @param {orderItemModel} item
* @return if item has been added to an existing parcel or not
*/
function fitInExistingParcels(parcels, item) {
// prevent items with bad weight to fit into boxes
if (!item.weight || item.weight <= 0) return false;
const parcelsLen = parcels.length;
for (let i = 0; i < parcelsLen; ++i) {
const parcel = parcels[i];
if (parcel.weight + item.weight < parcel.box.maxWeight) {
parcels[i].addItem(item);
return true;
}
}
return false;
}
/**
* Sort a list of boxModel based on their maxWeight ascending, the smaller first
* This is required to optimize the greedy parcel algorithm
* @param {[boxModel]} boxes the boxes to sort
* @return boxes array sorted by maxWeight
*/
function sortBoxes(boxes) {
return boxes.sort((first, second) => first.maxWeight - second.maxWeight);
}
/**
* Sort the given list of items based on their weight, the heavier first
* This is required to optimize the greedy parcel algorithm
* @param {[orderItemModel]} items the items to sort
* @param {string} mode either DESC or ASC, for descending or ascending. DESC is by default, if anything else is specified ASC is assumed
* @returns sorted items be weight
*/
function sortItems(items, mode = 'DESC') {
return items.sort((first, second) => (mode === 'DESC'
? second.weight - first.weight
: first.weight - second.weight));
}
/**
* Find the box with the minimum maxWeight bigger than weight
* This is used to see in which box to put items
* @param {[boxModel]} boxes a list of available boxes sorted by maxWeight ascending
* @param {number} weight the weight to look the box for
* @return the matching boxModel or null if none could be found
*/
function findBestFittingBox(boxes, weight) {
// sanity check
if (!weight || weight <= 0) return null;
// loop for all the available box sizes
for (const box of boxes) {
if (box.maxWeight > weight) {
return box;
}
}
return null;
}
/**
* Extract the items from the given order,
* populate all the required shipping details from the matching products
* and explode it to the actual number of individual items to be shipped.
* @param {orderModel} order
* @returns list of individually shippable items
*/
async function getIndividualItems(order) {
// order items do have a quantity (1 to a lot)
// but the algorithm will refer to items invidually
// so we need to split the orderItems into individual items
const items = [];
for (const orderItem of order.items) {
// There are some properties needed in the matching product
const product = await getValidProduct(orderItem, order.partnerId, true);
if (!product) continue; // this items will not be shipped
// Import product data into order item (shopify doesnt specify item weight in line items)
orderItem.weight = product.weight;
// Import product details in order item for future reference
console.log(`setting product properties in item ${orderItem.name}: weight ${product.weight}`);
for (let i = 0; i < orderItem.quantity; ++i) {
// there is no memory allocation here, just filling in an array with references
items.push(orderItem);
}
}
return items;
}
/**
* Applies a cool algorithm to estimate the optimal set of boxes to be used for the given items
* The result is stored in parcel, where items are associated with a box type.
* @param {[orderItemModel]} items individual items to be shipped
* @param {[boxModel]} boxes a list of boxes
* @return a list of parcels for the items
*/
function estimateParcels(items, boxes) {
// ensure boxes are ordered by weight ascending to simplify box allocation
sortBoxes(boxes);
// order the items by weight descending (First Fit algorithm)
sortItems(items);
// prepare the parcel list to be returned
const parcels = [];
// prepare a parcel object to store data as we go
let parcel = new parcelModel();
// now for each item, see if it fits in the parcel, if not, take a bigger box and move on...
for (const item of items) {
// AG1263 - setting item size to 10cm if shippable but not provided
if (item.requiresShipping) {
item.length = item.length || 100;
item.width = item.width || 100;
item.height = item.height || 100;
}
// see if this item can fit in a previous parcel
// if it does fit in, move on to the next
if (fitInExistingParcels(parcels, item)) continue;
// find the smallest box for the total items weight
const box = findBestFittingBox(boxes, parcel.weight + item.weight);
if (box) {
// use this box for the parcel
parcel.addItem(item);
parcel.setBox(box);
} else {
// there is not enough room for this item in the current parcel
// add the current parcel, as it is full (but only if it had items in it)
if (parcel.items.length > 0) {
parcels.push(parcel);
// ensure the parcel is reset to start fitting the new item
parcel = new parcelModel();
}
// look for a fitting box for this item alone
const bestBox = findBestFittingBox(boxes, item.weight);
// assign the item to the parcel
parcel.addItem(item);
if (bestBox) {
// if a box was found, lets use it
parcel.setBox(bestBox);
} else {
console.log(`warning: computing parcels, no box big enough for item ${item.name} (${item.weight}g)!`);
// if there is no fitting box for this item, let it go alone, no box associated free like the wind
// prepare a custom box for the item (the item itself will be shipped as is)
parcel.setBox(prepareBox(item.name, item.height, item.length, item.width, item.weight > 0 ? item.weight : 0, 0));
// force this parcel in
parcels.push(parcel);
// reset the parcel again, and loop with the next item hoping to find a suitable box
parcel = new parcelModel();
}
}
}
// eventually, if there are items in the parcel, add it to the list
if (parcel.items.length > 0) {
parcels.push(parcel);
}
return parcels;
}
/**
* Create a manifest for the given shipment
*
* @param {String} shipmentId
* @returns
*/
async function createManifest(shipmentId) {
const createManifestQuery = gql`
mutation createManifest ($shipmentId: String!) {
createManifest (shipmentId: $shipmentId) {
manifestUrl,
manifestNumber
}
}
`;
const manifestVariables = {
shipmentId,
};
const manifest = await smsClient.request(createManifestQuery, manifestVariables);
console.log(`Created manifest ${manifest.manifestNumber} for shipment ${shipmentId}`);
return manifest;
}
/**
* Request a pickup at the given time
* for the given shipment
* to the specified carrier
*
* @param {String} carrierId
* @param {ShipmentBaseInfo} shipmentBaseInfo
* @param {Date} pickupTime
*/
async function bookPickup(carrierId, shipmentBaseInfo, pickupTime, orderIds) {
const pickupRequest = new bookPickupModel();
// Populate the pickup request with the given arguments
pickupRequest.from = shipmentBaseInfo.from;
pickupRequest.to = shipmentBaseInfo.to;
pickupRequest.parcels = shipmentBaseInfo.parcels;
pickupRequest.carrierId = carrierId;
pickupRequest.pickupTime = Math.round(pickupTime);
pickupRequest.pickupNote = 'Pickup automatically generated by INDACO Logistics';
pickupRequest.pickupMorningMinTime = '07:00';
pickupRequest.pickupMorningMaxTime = '12:00';
pickupRequest.pickupAfternoonMinTime = '13:00';
pickupRequest.pickupAfternoonMaxTime = '18:00';
// set orderIds (needed from some carriers)
pickupRequest.orderIds = orderIds;
// Definining the query and setting the variables
const bookPickupMutation = gql`
mutation bookPickup ($pickupData: BookPickupInput!) {
bookPickup (pickupData: $pickupData) {
result,
confirmationId,
error,
message
}
}`;
const variables = {
pickupData: pickupRequest,
};
// Performing the request
console.log(`Issuing pickup request with carrier ${carrierId}`);
const response = await smsClient.request(bookPickupMutation, variables);
// Returning the pickup confirmation id
return response.bookPickup.confirmationId;
}
/**
* Request the SMS to create a shipment for an order
*
* @param {String} orderId the order to be shipped
* @param {String} carrierId the carrier to be used
* @param {String} rateId the rate identifier, obtained by getRates function
* @param {ShipmentBaseInfo} shipmentBaseInfo basic information about the shipment
* @returns shipment creation data
*/
async function createShipment(orderId, carrierId, rateId, shipmentBaseInfo) {
console.log(`Creating shipment for ${orderId} using ${carrierId} rate ${rateId}`);
// Populating the shipment info with the extended parameters
const shipmentInfo = createShipmentInfo(orderId, carrierId, rateId, shipmentBaseInfo);
// Definining the query and setting the variables
const createShipmentQuery = gql`
mutation createShipment ($input: ShipmentInfoInput!) {
createShipment (shipmentData: $input) {
shipmentId,
labelUrl,
trackingNumber,
trackingCarrier,
}
}`;
const variables = {
input: shipmentInfo,
};
// Perform the graphql query to sms
const response = await smsClient.request(createShipmentQuery, variables);
console.log(response);
console.log(`Shipment for order ${orderId} created: ${response.createShipment.shipmentId}`);
return response.createShipment;
}
/**
* Request the SMS the available rates for a given set of shipment details.
*
* @param {ShipmentBaseInfo} shipmentBaseInfo
* @returns
*/
async function fetchRates(shipmentBaseInfo) {
// Preparing the query and the setting the variables
const fetchRatesQuery = gql`
query fetchRates($input: ShipmentBaseInfoInput!) {
fetchRates (shipmentInfo: $input) {
rates {
carrier {
smsid,
name,
service,
}
rate,
rateId,
deliveryDays,
currency
}
}
}`;
const variables = {
input: shipmentBaseInfo,
};
// We are going to return a list of rates
const rates = []; // This will be populated in the loop below when a response is received
// Perform the graphql query to sms
const response = await smsClient.request(fetchRatesQuery, variables)
.then((resp) => {
for (const rate of resp.fetchRates.rates) {
rates.push(rate);
}
})
.catch((err) => { console.log(err); });
return rates;
}
/**
* Return the best rate among the ones provided
* @param {[ShipmentRate]} rates
* @param {[string]} carrierIds if specified filter the rates to only accept rates from the given set of carrier sms ids
* @return {ShipmentRate} the best rate or null if there are no rates
*/
function getBestRate(rates, carrierIds = null) {
let bestRate = null;
// For all the shipment rates
for (const rate of rates) {
// if a list was specified and it doesnt include the given sms id, skip this rate
// this means an empty list will result in no rates
if (carrierIds
&& !carrierIds.includes(rate.carrier.smsid)
) {
continue;
}
// Keep the rate with the lowest "rate", that is, the estimated shipment cost
if (!bestRate || rate.rate < bestRate.rate) {
bestRate = rate;
}
}
return bestRate;
}
/**
* Return the first day available at the warehouse for the pickup of the order.
* @param {Date} orderDate
* @returns {Date} day for the pickup
*/
function getPickupDate(orderDate) {
const pickupDate = orderDate;
let weekendOrder = false;
if (orderDate.getDay() === 6 || orderDate.getDay() === 0 || (orderDate.getDay() === 5 && orderDate.getHours() >= 13 && orderDate.getHours() <= 23)) {
// it's a weekend order, it will ship on tuesday
weekendOrder = true;
}
// give at least 24 hours to the pickup
pickupDate.setDate(orderDate.getDate() + 1);
// skip Saturday and Sunday
while (pickupDate.getDay() === 6 || pickupDate.getDay() === 0) {
pickupDate.setDate(pickupDate.getDate() + 1);
}
// set pickup next working day at 15:30 if the order has been requested in the morning
if (orderDate.getHours() < 13 && !weekendOrder) {
pickupDate.setHours(15);
pickupDate.setMinutes(0);
// set pickup after 2 working day at 9:30 if the order has been requested in the afternoon
} else if ((orderDate.getHours() >= 13 && orderDate.getHours() <= 23) || weekendOrder) {
pickupDate.setDate(pickupDate.getDate() + 1);
while (pickupDate.getDay() === 6 || pickupDate.getDay() === 0) {
pickupDate.setDate(pickupDate.getDate() + 1);
}
pickupDate.setHours(9);
pickupDate.setMinutes(30);
}
return pickupDate;
}
/**
* Look for carriers supported by the given partner
* @param {*} partnerId the partner database id to look for
* @return a list of sms ids
*/
async function getCarrierIds(partnerId, refrigerated, localOrder) {
const partnerCarriers = await carrierModel.find({ partnerId });
// extract carrier sms ids
const carrierIds = [];
for (const carrier of partnerCarriers) {
console.log(`${carrier.refrigerated}-${refrigerated}`);
if (localOrder) {
// carrierIds.push('1942'); // DHL
carrierIds.push('8396'); // BRT
console.log('aggiunto corriere Trentino per spedizioni locali');
break;
}
if (refrigerated === carrier.refrigerated) {
carrierIds.push(carrier.smsid);
console.log(`pushed ${carrier.smsid}. order refrigerated? ${refrigerated}, carrier refrigerated? ${carrier.refrigerated}`);
}
}
return carrierIds;
}
async function saveReferences(order) {
console.log('picking quantities...');
for (let i = 0; i < order.items.length; ++i) {
const item = order.items[i];
const reference = await productModel.findOne({ 'offers.sku': item.sku });
console.log(`saving ${reference.title}`);
await ims.referenceUpdate(reference, reference.sku);
}
}
/**
* pick quantities subtracting them from the booked and the inventoryLevel ones
* @param {orderModel} order
* @return a list of sms ids
*/
async function pickQuantities(order, bestWarehouseAddress) {
console.log('picking quantities...');
if (!order.fullyPicked) {
for (let i = 0; i < order.items.length; ++i) {
const item = order.items[i];
const reference = await productModel.findOne({ 'offers.sku': item.sku });
// adjust inventory level first
for (let j = 0; j < reference.inventoryLevels.length; j++) {
if (reference.inventoryLevels[j].warehouseId.equals(bestWarehouseAddress.warehouse._id)) {
let quantity = item.quantity;
console.log(`[PICKING] inventory level: ${reference.inventoryLevels[j]}`);
console.log(`[PICKING] starting quantity: ${item.quantity}`);
console.log('looping through booked quantites first');
for (let k = 0; k < reference.inventoryLevels[j].bookings.length; k++) {
if (reference.inventoryLevels[j].bookings[k].omsgid === order.omsgid && !reference.inventoryLevels[j].bookings[k].fulfilled) {
console.log('found a booking, marking it as fulfilled');
reference.inventoryLevels[j].bookings[k].fulfilled = true;
quantity -= reference.inventoryLevels[j].bookings[k].amount;
console.log(`[PICKING] picked quantity: ${reference.inventoryLevels[j].bookings[k].amount}`);
console.log(`[PICKING] remaining quantity: ${quantity}`);
}
}
// sanity check
if (quantity < 0) {
console.log('############### QUANTITY IS LESS THAN ZERO ###################');
console.log('The booking procedure experienced an issue, since booking amounts are higher than the ordered quantity.');
}
if (quantity > 0) {
console.log('remaining product found, removing it from availability...');
console.log(`current availability: ${reference.inventoryLevels[j].amount}`);
reference.inventoryLevels[j].amount -= quantity;
console.log(`removed ${item.quantity} from inventory level, new total ${reference.inventoryLevels[j].amount}`);
await ims.addEntryToWarehouseJournal(bestWarehouseAddress.warehouse._id, reference._id, order.partnerId, item.quantity, reference.inventoryLevels[j].amount, 'ORDER', item.refrigerated);
}
await reference.save();
}
}
await reference.save();
order.fullyPicked = true;
await order.save();
}
}
}