import { models } from './models/sequelize.js'; import { Op } from 'sequelize'; import { v4 as uuidv4 } from 'uuid'; import axios from 'axios'; import moment from 'moment'; import chalk from 'chalk'; import ora from 'ora'; const env = process.env; let spinner; /** * Gets the Access Token. * * @param {Boolean} isForce Force to request new token */ export async function requestToken(isForce = false) { await startSpin(`Requesting new token...`, true); let optionalMsg; try { const clientConfigData = await models.config.findOne(); let tokenExpiration, token; if (clientConfigData) { token = clientConfigData.currentToken; tokenExpiration = clientConfigData.tokenExpiration; } if (isForce || !token || !tokenExpiration || moment().isAfter(tokenExpiration)) { const clientId = JSON.parse(env.USE_SECRETS_DB) ? clientConfigData.clientId || env.CLIENT_ID : env.CLIENT_ID; const clientSecret = JSON.parse(env.USE_SECRETS_DB) ? clientConfigData.clientSecret || env.CLIENT_SECRET : env.CLIENT_SECRET; const data = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, scope: 'role:app catalog:read supply:read organization:read network:write network:read' }).toString(); const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; const response = (await vnRequest('POST', env.API_ENDPOINT, data, headers)).data; const tokenExpiration = moment() .add(response.expires_in, 's') .format('YYYY-MM-DD HH:mm:ss'); await updateClientConfig({ clientId, clientSecret, currentToken: response.access_token, tokenExpiration, }); } else optionalMsg = 'Using stored token...'; await okSpin(optionalMsg, true); } catch (err) { await failSpin(err, true); } }; /** * Returns the current token. * * @returns {String} The current token */ export async function getCurrentToken() { return (await models.config.findOne()).currentToken; }; /** * Check the floriday data config. */ export async function checkConfig() { await startSpin(`Checking config...`, true); const excludedEnvVars = ['VSCODE_GIT_ASKPASS_EXTRA_ARGS']; const requiredEnvVars = Object.keys(env); const filteredEnvVars = requiredEnvVars.filter(reqEnvVar => !excludedEnvVars.includes(reqEnvVar)); for (const reqEnvVar of filteredEnvVars) if (!process.env[reqEnvVar]) await failSpin(new Error(`You haven't provided the ${reqEnvVar} environment variable`), true); const clientConfigData = await models.config.findOne(); if (!clientConfigData) await updateClientConfig(env.CLIENT_ID, env.CLIENT_SECRET); await okSpin(null, true); }; /** * Returns the expiration of current token. * * @returns {String} The expiration of current token */ export async function getCurrentTokenExpiration() { return (await models.config.findOne()).tokenExpiration; }; /** * Updates the access token in the client config table. * * @param {Array} clientConfig [clientId, clientSecret, currenToken, tokenExpiration] */ export async function updateClientConfig(clientConfig) { try { if (!JSON.parse(process.env.USE_SECRETS_DB)) clientId = clientSecret = null await models.config.upsert({ id: 1, ...clientConfig, }); } catch (err) { throw(err); } }; /** * Pauses the execution of the script for the specified number of milliseconds. * * @param {Integer} ms */ export async function sleep(ms) { await new Promise(resolve => setTimeout(resolve, ms)); }; /** * Sync a model. * * @param {String} model Supported models (organization | warehouse | tradeItem | supplyLine | clockPresaleSupply) */ export async function syncModel(model) { await startSpin(`Syncing ${model}...`, true); let i = 0; try { const dbSeqNum = await models.sequenceNumber.findOne({ where: { model } }) let curSeqNum = dbSeqNum?.maxSequenceNumber ?? 0; let maxSeqUrl, syncUrl; switch (model) { case 'organization': maxSeqUrl = `${env.API_URL}/organizations/current-max-sequence`; syncUrl = `${env.API_URL}/organizations/sync/`; break; case 'warehouse': maxSeqUrl = `${env.API_URL}/warehouses/current-max-sequence`; syncUrl = `${env.API_URL}/warehouses/sync/`; break; case 'tradeItem': maxSeqUrl = `${env.API_URL}/trade-items/current-max-sequence`; syncUrl = `${env.API_URL}/trade-items/sync/`; break; case 'supplyLine': maxSeqUrl = `${env.API_URL}/supply-lines/current-max-sequence`; syncUrl = `${env.API_URL}/supply-lines/sync/`; break; case 'clockPresaleSupply': maxSeqUrl = `${env.API_URL}/auction/clock-presales-supply/max-sequence-number`; syncUrl = `${env.API_URL}/auction/clock-presales-supply/sync/`; break; default: throw new Error('Unsupported model'); } const maxSeqNum = (await vnRequest('GET', maxSeqUrl)).data; for (curSeqNum; curSeqNum < maxSeqNum; curSeqNum++) { let params, misSeqNum; if (model === 'organization') params = new URLSearchParams({ organizationType: 'SUPPLIER'} ).toString(); else if (model === 'supplyLine' ) params = new URLSearchParams({ postFilterSelectedTradeItems: false }).toString(); else if (model === 'tradeItem') params = new URLSearchParams({ postFilterSelectedTradeItems: false, postFilterSelectedTradeItemPackingConfigurations: false, }).toString(); const res = (await vnRequest('GET', `${syncUrl}${curSeqNum}${params ? `?${params}` : ''}`)).data; curSeqNum = res.maximumSequenceNumber; const objects = res.results; misSeqNum = maxSeqNum - curSeqNum; txtSpin(`Syncing ${i} ${model}, ${misSeqNum} missing...`); switch (model) { case 'organization': await insertOrganizations(objects); break; case 'warehouse': await insertWarehouses(objects); break; case 'tradeItem': await insertTradeItems(objects); break; case 'supplyLine': await insertSupplyLines(objects); break; case 'clockPresaleSupply': await insertClockPresalesSupply(objects); break; default: throw new Error('Unsupported model'); } txtSpin(`Syncing ${i = i + objects.length} ${model}, ${misSeqNum} missing...`); await insertSequenceNumber(model, curSeqNum); } await insertSequenceNumber(model, maxSeqNum); txtSpin((i) ? `Syncing ${i} ${model}...` : `Syncing ${model}...`); await okSpin(null, true); } catch (err) { await failSpin(err, true); } }; /** * Create the connections in Floriday. */ export async function syncConnections(){ await deleteConnections(); try { let connectionsInDb = await models.organization.findAll({ where: { isConnected: true, companyGln: { [Op.ne]: null }, rfhRelationId: { [Op.ne]: null }, } }); const connectionsInFloriday = (await vnRequest('GET', `${env.API_URL}/connections`)).data; let isExists = false, connectionsToPut = []; for (let connectionInDb of connectionsInDb) { for (let connectionInFloriday of connectionsInFloriday) if (connectionInFloriday == connectionInDb.organizationId) { isExists = true; break; } if (!isExists) connectionsToPut.push(connectionInDb.organizationId) isExists = false; } if (connectionsToPut.length) await startSpin(`Creating connections in Floriday...`, true); let i = 1; for (let connection of connectionsToPut) { txtSpin(`Creating ${i++} of ${connectionsToPut.length} connections in Floriday...`); await vnRequest('PUT', `${env.API_URL}/connections/${connection}`); } await okSpin(null, true); } catch (err) { await failSpin(err, true); } }; /** * Insert sequence number in the database. * * @param {String} model The model identifier * @param {Number} sequenceNumber The sequence number */ export async function insertSequenceNumber(model, sequenceNumber) { const tx = await models.sequelize.transaction(); try { await models.sequenceNumber.upsert({ model: model, maxSequenceNumber: sequenceNumber, }, { transaction: tx }); await tx.commit(); } catch (err) { await tx.rollback(); throw err; } }; /** * Insert trade items and dependencies in the database. * * @param {Array} tradeItems An array containing the trade item data to be inserted */ export async function insertTradeItems(tradeItems) { const tx = await models.sequelize.transaction(); try { const tradeItemsData = tradeItems.map((tradeItem) => ({ ...tradeItem, organizationId: tradeItem.supplierOrganizationId, lastSync: moment().format('YYYY-MM-DD HH:mm:ss'), })); await models.tradeItem.bulkCreate(tradeItemsData, { updateOnDuplicate: [ 'tradeItemId', 'code', 'gtin', 'vbnProductCode', 'name', 'isDeleted', 'sequenceNumber', 'tradeItemVersion', 'isCustomerSpecific', 'isHiddenInCatalog', 'organizationId', ], transaction: tx, }); const characteristics = []; const seasonalPeriods = []; const photos = []; const packingConfigurations = []; const countryOfOriginIsoCodes = []; const botanicalNames = []; for (const tradeItem of tradeItemsData) { if (tradeItem.characteristics?.length) for (const characteristic of tradeItem.characteristics) characteristics.push({ tradeItemId: tradeItem.tradeItemId, ...characteristic, }); if (tradeItem.seasonalPeriods?.length) for (const seasonalPeriod of tradeItem.seasonalPeriods) seasonalPeriods.push({ tradeItemId: tradeItem.tradeItemId, ...seasonalPeriod, }); if (tradeItem.photos?.length) for (const photo of tradeItem.photos) photos.push({ ...photo, tradeItemId: tradeItem.tradeItemId, }); if (tradeItem.packingConfigurations?.length) { for (const packingConfiguration of tradeItem.packingConfigurations) { const uuid = uuidv4(); packingConfigurations.push({ packingConfigurationId: uuid, ...packingConfiguration, additionalPricePerPiece_currency: packingConfiguration.additionalPricePerPiece.currency, additionalPricePerPiece_value: packingConfiguration.additionalPricePerPiece.value, tradeItemId: tradeItem.tradeItemId, }); models.package.upsert({ ...packingConfiguration.package, packingConfigurationId: uuid, }, { transaction: tx }); } } if (tradeItem.countryOfOriginIsoCodes?.length) for (const isoCode of tradeItem.countryOfOriginIsoCodes) countryOfOriginIsoCodes.push({ isoCode, tradeItemId: tradeItem.tradeItemId, }); if (tradeItem.botanicalNames?.length) for (const botanicalName of tradeItem.botanicalNames) botanicalNames.push({ botanicalNameId: uuidv4(), name: botanicalName, tradeItemId: tradeItem.tradeItemId, }); } if (characteristics?.length) await models.characteristic.bulkCreate(characteristics, { updateOnDuplicate: ['tradeItemId', 'vbnCode', 'vbnValueCode'], transaction: tx, }); if (seasonalPeriods?.length) await models.seasonalPeriod.bulkCreate(seasonalPeriods, { updateOnDuplicate: ['tradeItemId', 'startWeek', 'endWeek'], transaction: tx, }); if (photos?.length) await models.photo.bulkCreate(photos, { updateOnDuplicate: ['tradeItemId', 'url', 'type', 'primary'], transaction: tx, }); if (packingConfigurations?.length) await models.packingConfiguration.bulkCreate(packingConfigurations, { updateOnDuplicate: [ 'packingConfigurationId', 'piecesPerPackage', 'bunchesPerPackage', 'photoUrl', 'packagesPerLayer', 'layersPerLoadCarrier', 'additionalPricePerPiece_currency', 'additionalPricePerPiece_value', 'transportHeightInCm', 'loadCarrierType', 'isPrimary', ], transaction: tx, }); if (countryOfOriginIsoCodes?.length) await models.countryOfOriginIsoCode.bulkCreate(countryOfOriginIsoCodes, { updateOnDuplicate: ['tradeItemId', 'isoCode'], transaction: tx, }); if (botanicalNames?.length) await models.botanicalName.bulkCreate(botanicalNames, { updateOnDuplicate: ['botanicalNameId', 'name', 'tradeItemId'], transaction: tx, }); await tx.commit(); } catch (err) { await tx.rollback(); throw err; } }; /** * Insert clock presales supply in the database. * * @param {Array} clockPresalesSupply An array containing the clockPresaleSupplies data to be inserted */ export async function insertClockPresalesSupply(clockPresalesSupply) { const tx = await models.sequelize.transaction(); try { const clockPresalesSupplyWithDefaults = clockPresalesSupply.map((clockPresaleSupply) => ({ ...clockPresaleSupply, pricePerPiece_currency: clockPresaleSupply.pricePerPiece.currency, pricePerPiece_value: clockPresaleSupply.pricePerPiece.value, organizationId: clockPresaleSupply.supplierOrganizationId, })); await models.clockPresaleSupply.bulkCreate(clockPresalesSupplyWithDefaults, { updateOnDuplicate: [ 'supplyLineId', 'status', 'tradeItemId', 'pricePerPiece_currency', 'pricePerPiece_value', 'deliveryNoteReference', 'numberOfPieces', 'packingConfigurations', 'tradePeriod_startDateTime', 'tradePeriod_endDateTime', 'organizationId', 'tradeInstrument', 'salesChannel', 'sequenceNumber', 'creationDateTime', 'lastModifiedDateTime', ], transaction: tx, }); await tx.commit(); } catch (err) { await tx.rollback(); throw err; } }; /** * Insert warehouses in the database. * * @param {Array} warehouses An array containing the warehouses data to be inserted */ export async function insertWarehouses(warehouses) { const tx = await models.sequelize.transaction(); try { const warehousesWithDefaults = warehouses.map((warehouse) => ({ ...warehouse, location_gln: warehouse.location.gln, location_address_addressLine: warehouse.location.address.addressLine, location_address_city: warehouse.location.address.city, location_address_countryCode: warehouse.location.address.countryCode, location_address_postalCode: warehouse.location.address.postalCode, location_address_stateOrProvince: warehouse.location.address.stateOrProvince, lastSync: moment().format('YYYY-MM-DD HH:mm:ss'), })); await models.warehouse.bulkCreate(warehousesWithDefaults, { updateOnDuplicate: [ 'warehouseId', 'name', 'location_gln', 'location_address_addressLine', 'location_address_city', 'location_address_countryCode', 'location_address_postalCode', 'location_address_stateOrProvince', 'isDeleted', 'sequenceNumber', 'organizationId', 'lastSync', ], transaction: tx, }); await tx.commit(); } catch (err) { await tx.rollback(); throw err; } }; /** * Insert organizations in the database. * * @param {Array} organizations An array containing the organizations data to be inserted */ export async function insertOrganizations(organizations) { const tx = await models.sequelize.transaction(); try { const organizationsWithDefaults = organizations.map((organization) => ({ ...organization, isConnected: JSON.parse(env.ORGS_ALWAYS_CONN), lastSync: moment().format('YYYY-MM-DD HH:mm:ss'), })); await models.organization.bulkCreate(organizationsWithDefaults, { updateOnDuplicate: [ 'organizationId', 'sequenceNumber', 'companyGln', 'name', 'commercialName', 'email', 'phone', 'website', 'rfhRelationId', 'paymentProviders', 'endDate', 'mailingAddress', 'physicalAddress', 'isConnected', 'lastSync', ], transaction: tx, }); await tx.commit(); } catch (err) { await tx.rollback(); throw err; } }; /* Checkear dependecias supply line // Check if the warehouse exists, and if it doesn't, create it let warehouse = await models.warehouse.findOne({ where: { warehouseId: supplyLine.warehouseId } }); if (!warehouse) { let warehouse = (await vnRequest('GET', `${env.API_URL}/warehouses/${supplyLine.warehouseId}`)).data; // Check if the organization exists, and if it doesn't, create it let organization = await models.organization.findOne({ where: { organizationId: warehouse.organizationId } }, { transaction: tx }); if (!organization) { let organization = (await vnRequest('GET', `${env.API_URL}/organizations/${warehouse.organizationId}`)).data; await insertOrganization(organization); } await insertWarehouse(warehouse); } // Check if the trade item exists, and if it doesn't, create it let tradeItem = await models.tradeItem.findOne({ where: { tradeItemId: supplyLine.tradeItemId } }, { transaction: tx }); if (!tradeItem) { let tradeItem = (await vnRequest('GET', `${env.API_URL}/trade-items/${supplyLine.tradeItemId}`)).data; await insertTradeItem(tradeItem); } */ /** * Insert supply lines and dependencies in the database. * * @param {Array} supplyLines An array containing the supply line data to be inserted */ export async function insertSupplyLines(supplyLines) { const tx = await models.sequelize.transaction(); try { const supplyLinesData = supplyLines.map((supplyLine) => ({ ...supplyLine, organizationId: supplyLine.supplierOrganizationId, deliveryPeriodStartDateTime: supplyLine.deliveryPeriod?.startDateTime ?? null, deliveryPeriodEndDateTime: supplyLine.deliveryPeriod?.endDateTime ?? null, orderPeriodStartDateTime: supplyLine.orderPeriod?.startDateTime ?? null, orderPeriodEndDateTime: supplyLine.orderPeriod?.endDateTime ?? null, agreementReference_code: supplyLine.agreementReference?.code ?? null, agreementReference_description: supplyLine.agreementReference?.description ?? null, lastSync: moment().format('YYYY-MM-DD HH:mm:ss'), })); await models.supplyLine.bulkCreate(supplyLinesData, { updateOnDuplicate: [ 'supplyLineId', 'status', 'numberOfPieces', 'deliveryPeriodStartDateTime', 'deliveryPeriodEndDateTime', 'orderPeriodStartDateTime', 'orderPeriodEndDateTime', 'warehouseId', 'sequenceNumber', 'type', 'isDeleted', 'salesUnit', 'agreementReferenceCode', 'agreementReferenceDescription', 'isLimited', 'isCustomerSpecific', 'tradeItemId', 'organizationId', 'lastSync', ], transaction: tx, }); const packingConfigurations = []; const volumePrices = []; for (const supplyLine of supplyLinesData) if (supplyLine.packingConfigurations?.length) { for (const packingConfiguration of supplyLine.packingConfigurations) packingConfigurations.push({ packingConfigurationId: uuidv4(), ...packingConfiguration, packageVbnPackageCode: packingConfiguration.package.vbnPackageCode, packageCustomPackageId: packingConfiguration.package.customPackageId, additionalPricePerPieceCurrency: packingConfiguration.additionalPricePerPiece.currency, additionalPricePerPieceValue: packingConfiguration.additionalPricePerPiece.value, supplyLineId: supplyLine.supplyLineId, }); if (supplyLine.volumePrices?.length) for (const volumePrice of supplyLine.volumePrices) volumePrices.push({ supplyLineId: supplyLine.supplyLineId, ...volumePrice, }); } if (packingConfigurations.length) await models.supplyLinePackingConfiguration.bulkCreate(packingConfigurations, { updateOnDuplicate: [ 'packingConfigurationId', 'packageVbnPackageCode', 'packageCustomPackageId', 'piecesPerPackage', 'bunchesPerPackage', 'photoUrl', 'packagesPerLayer', 'layersPerLoadCarrier', 'transportHeightInCm', 'loadCarrierType', 'additionalPricePerPieceCurrency', 'additionalPricePerPieceValue', 'isPrimary' ], transaction: tx, }); if (volumePrices.length) await models.volumePrice.bulkCreate(volumePrices, { updateOnDuplicate: ['supplyLineId', 'unit', 'pricePerPiece'], transaction: tx, }); await tx.commit(); } catch (err) { await tx.rollback(); throw err; } }; /** * Removes Floriday connections that we don't have in the database. **/ export async function deleteConnections() { try { let i = 1; const connectionsInFloriday = (await vnRequest('GET', `${env.API_URL}/connections`)).data; const connectionsInDb = await models.organization.findAll({ attributes: ['organizationId'], where: { isConnected: true } }); let isExists = false, ghostConnections = []; for (let connectionInFloriday of connectionsInFloriday) { for (let connectionInDb of connectionsInDb) if (connectionInFloriday == connectionInDb.organizationId) { isExists = true; break; } if (!isExists) ghostConnections.push(connectionInFloriday) isExists = false; } if (ghostConnections.length) await startSpin(`Deleting connections that aren't in the db...`, true); for (let connection of ghostConnections) { await vnRequest('DELETE', `${env.API_URL}/connections/${connection}`); txtSpin(`Deleting ${i++} of ${ghostConnections.length} connections that aren't in the db...`); } await okSpin(null, true); } catch (err) { await criticalSpin(err); } }; /** * Perform a REST request. * * @param {String} url The URL of the REST request * @param {String} method The HTTP method of the request (e.g., GET, POST, PUT, DELETE) * @param {Array} body The body of the request, typically an array of data to be sent * @param {Array} header The headers of the request, typically an array of key-value pairs * * @return {Array} An array containing the response data from the REST request **/ export async function vnRequest(method, url, data, headers) { if (!headers) headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${await getCurrentToken()}`, 'X-Api-Key': process.env.API_KEY, }; while(true) { try { return (['GET', 'DELETE'].includes(method)) ? await axios({method, url, headers}) : await axios({method, url, data, headers}); } catch (err) { switch (err.code) { case 'ECONNRESET': // Client network socket TLS case 'EAI_AGAIN': // getaddrinfo await warnSpin(null, err, false); await sleep(1000); await startSpin(null, false); break; case 'ECONNABORTED': case 'ECONNREFUSED': case 'ERR_BAD_REQUEST': switch (err.response.status) { case 404: // Not found return err; case 504: case 502: await warnSpin(null, err, false); await sleep(1000); await startSpin(null, false); break; case 429: // Too Many Requests await warnSpin(null, err, false); await sleep(60000); await startSpin(null, false); break; case 401: // Unauthorized await warnSpin(null, err, false); await requestToken(true); headers.Authorization ? headers.Authorization = `Bearer ${await getCurrentToken()}` : criticalError(err); await startSpin(null, false); break; default: await warnSpin(null, err, false); await sleep(env.MS_RETRY_UNHANDLED_ERROR); await startSpin(null, false); break; } break; default: await warnSpin(null, err, false); await sleep(env.MS_RETRY_UNHANDLED_ERROR); await startSpin(null, false); break; } } } }; /** * Sets the text of spinner. * * @param {String} msg Text of spinner * @param {Boolean} isNew Reinstantiate the object **/ export async function startSpin(msg, isNew) { if (JSON.parse(env.TIME_STAMPS) && msg) msg = `${chalk.gray(`[${new moment().format('YYYY-MM-DD hh:mm:ss A')}]`)} ${msg}`; (!isNew) ? spinner.start() : spinner = ora({ text: msg, indent: 1, spinner: 'arc', interval: 40, color: 'white', }).start(); }; /** * Sets the text of spinner. * * @param {String} msg Text of spinner * @param {Boolean} clear Clean the instance **/ export async function txtSpin(msg) { if (JSON.parse(env.TIME_STAMPS) && msg) msg = `${chalk.gray(`[${new moment().format('YYYY-MM-DD hh:mm:ss A')}]`)} ${msg}`; spinner.text = msg }; /** * Sets the spinner to ok. * * @param {String} msg Text of spinner * @param {Boolean} clear Clean the instance **/ export async function okSpin(msg, clear) { if (JSON.parse(env.TIME_STAMPS) && msg) msg = `${chalk.gray(`[${new moment().format('YYYY-MM-DD hh:mm:ss A')}]`)} ${msg ?? ''}`; if (spinner) { spinner.succeed(msg); if (clear) spinner.clear(); } }; /** * Sets the spinner to waning and throw a warning. * * @param {String} msg Text of spinner * @param {Error} err Error object * @param {Boolean} clear Clean the instance **/ export async function warnSpin(msg, err, clear) { if (JSON.parse(env.TIME_STAMPS) && msg) msg = `${chalk.gray(`[${new moment().format('YYYY-MM-DD hh:mm:ss A')}]`)} ${msg}`; if (spinner) { spinner.warn(msg); if (clear) spinner.clear(); } if (err) await warning(err); }; /** * Sets the spinner to fail and throw a error. * * @param {Error} err Error object * @param {Boolean} clear Clean the instance **/ export async function failSpin(err, clear) { if (spinner) { spinner.fail(); if (clear) spinner.clear(); } if (err) throw err; }; /** * Sets the spinner to fail and throw a critical error. * * @param {Error} err Error object **/ export async function criticalSpin(err) { if (spinner) { spinner.fail(); spinner.clear(); } await criticalError(err); }; /** * Critical error. * * @param {Error} err Error object **/ export async function criticalError(err) { const msg = `${chalk.red.bold(' └─────')} ${chalk.red.bold('[CRITICAL]')}`; console.log(`${msg} ${chalk.red(err.message)}`); process.exit(); }; /** * Warning. * * @param {Error} err **/ export async function warning(err) { const msg = `${chalk.yellow.bold(' └─────')} ${chalk.yellow.bold('[WARNING]')}`; console.log(`${msg} ${chalk.yellow((err.response?.status && err.response?.data?.message) ?? err.message)}`); };