trailpack-proxy-cart
Version:
eCommerce - Trailpack for Proxy Engine
1,014 lines (951 loc) • 33.6 kB
JavaScript
/* eslint no-console: [0] */
'use strict'
const Service = require('trails/service')
const csvParser = require('papaparse')
const _ = require('lodash')
const shortid = require('shortid')
const fs = require('fs')
const PRODUCT_UPLOAD = require('../../lib').Enums.PRODUCT_UPLOAD
const PRODUCT_META_UPLOAD = require('../../lib').Enums.PRODUCT_META_UPLOAD
/**
* @module ProductCsvService
* @description Product CSV Service
*/
module.exports = class ProductCsvService extends Service {
/**
*
* @param file
* @returns {Promise}
*/
productCsv(file) {
console.time('csv')
const uploadID = shortid.generate()
const ProxyEngineService = this.app.services.ProxyEngineService
const errors = []
let errorsCount = 0, lineNumber = 1
return new Promise((resolve, reject)=>{
const options = {
header: true,
dynamicTyping: true,
encoding: 'utf-8',
step: (results, parser) => {
parser.pause()
lineNumber++
return this.csvProductRow(results.data[0], uploadID)
.then(row => {
parser.resume()
})
.catch(err => {
errorsCount++
this.app.log.error('ROW ERROR', err)
errors.push(`Line ${lineNumber}: ${err.message}`)
parser.resume()
})
},
complete: (results, file) => {
console.timeEnd('csv')
results.upload_id = uploadID
ProxyEngineService.count('ProductUpload', { where: { upload_id: uploadID }})
.then(count => {
results.products = count
results.errors_count = errorsCount
results.errors = errors
// Publish the event
ProxyEngineService.publish('product_upload.complete', results)
return resolve(results)
})
.catch(err => {
errorsCount++
errors.push(err.message)
results.errors_count = errorsCount
results.errors = errors
return resolve(results)
})
},
error: (err, file) => {
return reject(err)
}
}
const fileString = fs.readFileSync(file, 'utf8')
// Parse the CSV/TSV
csvParser.parse(fileString, options)
})
}
/**
*
* @param row
* @param uploadID
*/
csvProductRow(row, uploadID) {
// Wrap this in a promise so we can gracefully handle an error
return Promise.resolve()
.then(() => {
const ProductUpload = this.app.orm.ProductUpload
const values = _.values(PRODUCT_UPLOAD)
const keys = _.keys(PRODUCT_UPLOAD)
const google = {}
const amazon = {}
const upload = {
upload_id: uploadID,
options: {},
properties: {},
images: [],
variant_images: [],
vendors: [],
collections: [],
associations: [],
tags: []
}
// clear the row key if it's data is undefined
_.each(row, (data, key) => {
if (typeof(data) === 'undefined' || data === '') {
row[key] = null
}
})
// Omit parts of the row that are completely nil
row = _.omitBy(row, _.isNil)
// If the resulting row is empty, then skip
if (_.isEmpty(row)) {
return Promise.resolve({})
}
// For each row key normalize the data
_.each(row, (data, key) => {
if (typeof(data) !== 'undefined' && data !== null && data !== '') {
const i = values.indexOf(key.replace(/^\s+|\s+$/g, ''))
const k = keys[i]
if (i > -1 && k) {
if (k === 'handle') {
upload[k] = this.app.services.ProxyCartService.handle(data)
}
else if (k === 'title') {
upload[k] = this.app.services.ProxyCartService.title(data)
}
else if (k === 'seo_title') {
upload[k] = this.app.services.ProxyCartService.title(data)
}
else if (k === 'seo_description') {
upload[k] = this.app.services.ProxyCartService.description(data)
}
else if (k === 'price') {
upload[k] = parseInt(data)
}
else if (k === 'compare_at_price') {
upload[k] = parseInt(data)
}
else if (k === 'tags') {
upload[k] = _.uniq(data.toString().split(',').map(tag => {
return tag.toLowerCase().trim()
}))
}
else if (k === 'images') {
upload[k] = data.toString().split(',').map(image => {
return image.trim()
})
}
else if (k === 'images_alt') {
upload[k] = data.toString().split('|').map(alt => {
return alt.trim()
})
}
else if (k === 'variant_images') {
upload[k] = data.toString().split(',').map(image => {
return image.trim()
})
}
else if (k === 'variant_images_alt') {
upload[k] = data.toString().split('|').map(alt => {
return alt.trim()
})
}
else if (k === 'collections') {
upload[k] = _.uniq(data.toString().split(',').map(collection => {
return collection.trim()
}))
}
else if (k === 'associations') {
upload[k] = data.toString().split(',').map(association => {
return association.trim()
})
}
else if (k === 'exclude_payment_types') {
upload[k] = data.toString().split(',').map(type => {
return type.trim()
})
}
else if (k === 'vendors') {
upload[k] = data.toString().split(',').map(vendor => {
return vendor.trim()
})
}
else if (k === 'shops') {
upload[k] = data.toString().split(',').map(shop => {
return shop.trim()
})
}
else if (k === 'shops_quantity') {
upload[k] = data.toString().split(',').map(shopQty => {
return parseInt(shopQty.trim())
})
}
else if (k === 'weight_unit') {
upload[k] = data.toString().toLowerCase().trim()
}
else if (k === 'inventory_policy') {
upload[k] = data.toString().toLowerCase().trim()
}
// Booleans
else if (k === 'published') {
upload[k] = data.toString().toLowerCase().trim() === 'false' ? false : true
}
else if (k === 'available') {
upload[k] = data.toString().toLowerCase().trim() === 'false' ? false : true
}
else if (k === 'requires_shipping') {
upload[k] = data.toString().toLowerCase().trim() === 'false' ? false : true
}
else if (k === 'requires_taxes') {
upload[k] = data.toString().toLowerCase().trim() === 'false' ? false : true
}
else if (k === 'requires_subscription') {
upload[k] = data.toString().toLowerCase().trim() === 'false' ? false : true
}
// Shopping
else if (k === 'g_product_category') {
google['g_product_category'] = data.toString().trim()
}
else if (k === 'g_gender') {
google['g_gender'] = data.toString().trim()
}
else if (k === 'g_age_group') {
google['g_age_group'] = data.toString().trim()
}
else if (k === 'g_mpn') {
google['g_mpn'] = data.toString().trim()
}
else if (k === 'g_adwords_grouping') {
google['g_adwords_grouping'] = data.toString().trim()
}
else if (k === 'g_adwords_label') {
google['g_adwords_label'] = data.toString().trim()
}
else if (k === 'g_condition') {
google['g_condition'] = data.toString().trim()
}
else if (k === 'g_custom_product') {
google['g_custom_product'] = data.toString().trim()
}
else if (k === 'g_custom_label_0') {
google['g_custom_label_0'] = data.toString().trim()
}
else if (k === 'g_custom_label_1') {
google['g_custom_label_1'] = data.toString().trim()
}
else if (k === 'g_custom_label_2') {
google['g_custom_label_2'] = data.toString().trim()
}
else if (k === 'g_custom_label_3') {
google['g_custom_label_3'] = data.toString().trim()
}
else if (k === 'g_custom_label_4') {
google['g_custom_label_4'] = data.toString().trim()
}
else if (k === 'metadata') {
// METADATA uploaded this way MUST be in JSON
let formatted = data.toString().trim()
if (this.app.services.ProxyCartService.isJson(formatted)) {
formatted = JSON.parse(formatted)
upload[k] = formatted
}
// else {
// upload[k] = formatted
// }
}
else if (k === 'discounts') {
// Discounts uploaded this way MUST be in JSON
let formatted = data.toString().trim()
if (this.app.services.ProxyCartService.isJson(formatted)) {
formatted = JSON.parse(formatted)
upload[k] = formatted
}
// else {
// upload[k] = formatted
// }
}
else {
upload[k] = data
}
}
else {
const optionsReg = new RegExp('^((Option \/).([0-9]+).(Name|Value))', 'g')
const propertyReg = new RegExp('^((Property Pricing \/).([0-9]+).(Name|Group|Value|Image|Multi Select))', 'g')
const matchOptions = optionsReg.exec(key)
if (
matchOptions
&& matchOptions[2] === 'Option /'
&& typeof matchOptions[3] !== 'undefined'
&& typeof matchOptions[4] !== 'undefined'
) {
const part = matchOptions[4].toLowerCase()
const index = Number(matchOptions[3]) - 1
if (typeof upload.options[index] === 'undefined') {
upload.options[index] = {
name: '',
value: ''
}
}
// TODO place this as the default product
// if (data.trim().toLowerCase() !== 'default value') {
upload.options[index][part] = typeof data === 'string' ? data.trim() : data
// }
}
const matchProperties = propertyReg.exec(key)
if (
matchProperties
&& matchProperties[2] === 'Property Pricing /'
&& typeof matchProperties[3] !== 'undefined'
&& typeof matchProperties[4] !== 'undefined'
) {
const part = matchProperties[4].toLowerCase().replace(' ','_')
const index = Number(matchProperties[3]) - 1
if (typeof upload.properties[index] === 'undefined') {
upload.properties[index] = {
name: '',
group: null,
value: '',
image: null
}
}
// TODO place this as the default product
// if (data.trim().toLowerCase() !== 'default value') {
upload.properties[index][part] = typeof data === 'string' ? data.trim() : data
// }
}
}
}
})
// Handle Options
upload.option = {}
_.map(upload.options, option => {
upload.option[option.name] = option.value
})
delete upload.options
// Handle Pricing Properties
upload.property_pricing = {}
_.map(upload.properties, property => {
upload.property_pricing[property.name] = {}
upload.property_pricing[property.name]['price'] = property.value
if (property.group) {
upload.property_pricing[property.name]['group'] = property.group
}
else {
upload.property_pricing[property.name]['group'] = null
}
if (property.image) {
upload.property_pricing[property.name]['image'] = property.image
}
else {
upload.property_pricing[property.name]['image'] = null
}
if (property.multi_select !== 'undefined') {
upload.property_pricing[property.name]['multi_select'] = property.multi_select
}
else {
upload.property_pricing[property.name]['multi_select'] = true
}
})
delete upload.properties
// Map images
upload.images = upload.images.map((image, index) => {
return {
src: image,
alt: upload.images_alt && upload.images_alt[index]
? this.app.services.ProxyCartService.description(upload.images_alt[index]) : ''
}
})
delete upload.images_alt
upload.images = upload.images.filter(image => image)
// Map variant images
upload.variant_images = upload.variant_images.map((image, index) => {
return {
src: image,
alt: upload.variant_images_alt && upload.variant_images_alt[index]
? this.app.services.ProxyCartService.description(upload.variant_images_alt[index]) : null
}
})
delete upload.variant_images_alt
upload.variant_images = upload.variant_images.filter(image => image)
// Map vendors
upload.vendors = upload.vendors.map(vendor => {
return {
handle: this.app.services.ProxyCartService.handle(vendor),
name: this.app.services.ProxyCartService.title(vendor)
}
})
// Filter out undefined
upload.vendors = upload.vendors.filter(vendor => vendor)
// Get only Unique handles
upload.vendors = _.uniqBy(upload.vendors, 'handle')
// Map collections
upload.collections = upload.collections.map(collection => {
if (collection !== '') {
let position
if (/:/.test(collection)) {
position = Number(collection.split(':')[1])
collection = collection.split(':')[0]
}
return {
handle: this.app.services.ProxyCartService.handle(collection),
title: collection,
product_position: position
}
}
})
// Filter out undefined
upload.collections = upload.collections.filter(collection => collection)
// Get only Unique handles
upload.collections = _.uniqBy(upload.collections, 'handle')
// Map tags
upload.tags = upload.tags.map(tag => {
if (tag !== '') {
return {
name: this.app.services.ProxyCartService.name(tag)
}
}
})
// Filter out undefined tags
upload.tags = upload.tags.filter(tag => tag)
// Get only Unique names
upload.tags = _.uniqBy(upload.tags, 'name')
// Map associations
upload.associations = upload.associations.map(association => {
const product = association.split(/:(.+)/)
const handle = this.app.services.ProxyCartService.handle(product[0])
const sku = this.app.services.ProxyCartService.title(product[1])
const res = {}
if (handle && handle !== '') {
res.handle = handle
if (sku && sku !== '') {
res.sku = sku
}
return res
}
return
})
// Filter out undefined
upload.associations = upload.associations.filter(association => association)
// Add google
upload.google = google
// Add amazon
upload.amazon = amazon
// Handle is required, if not here, then reject whole row
return ProductUpload.build(upload).save()
})
}
/**
*
* @param uploadId
* @returns {Promise}
*/
processProductUpload(uploadId) {
const ProductUpload = this.app.orm['ProductUpload']
let errors = [], productsTotal = 0, variantsTotal = 0, associationsTotal = 0, errorsCount = 0
return ProductUpload.batch({
where: {
upload_id: uploadId
},
offset: 0,
limit: 10,
attributes: ['handle'],
group: ['handle']
// distinct: true
}, (products) => {
const Sequelize = this.app.orm['Product'].sequelize
return Sequelize.Promise.mapSeries(products, product => {
return this.processProductGroup(product.handle, uploadId, {})
.then((results) => {
// Calculate the totals created
productsTotal = productsTotal + (results.products || 0)
variantsTotal = variantsTotal + (results.variants || 0)
associationsTotal = associationsTotal + (results.associations || 0)
errorsCount = errorsCount + (results.errors_count || 0)
errors = errors.concat(results.errors)
return results
})
.catch(err => {
errors.push(err.message)
return
})
})
})
.then(results => {
return ProductUpload.destroy({where: {upload_id: uploadId }})
.catch(err => {
errors.push(err.message)
return err
})
})
.then(destroyed => {
return this.processProductAssociationUpload(uploadId, {})
.then(results => {
// Calculate the totals created
associationsTotal = associationsTotal + (results.associations || 0)
return results
})
.catch(err => {
errors.push(err.message)
return
})
})
.then(() => {
const results = {
upload_id: uploadId,
products: productsTotal,
variants: variantsTotal,
associations: associationsTotal,
errors_count: errorsCount,
errors: errors
}
this.app.services.ProxyEngineService.publish('product_process.complete', results)
return results
})
}
/**
*
* @param handle
* @param uploadId,
* @param options
* @returns {Promise}
*/
processProductGroup(handle, uploadId, options) {
options = options || {}
this.app.log.debug('ProductCsvService.processProductGroup: processing', handle, uploadId)
const Product = this.app.orm['Product']
const ProductUpload = this.app.orm['ProductUpload']
const AssociationUpload = this.app.orm['ProductAssociationUpload']
const associations = []
const errors = []
let errorsCount = 0, productsCount = 0, variantsCount = 0, associationsCount = 0
return Product.sequelize.transaction(t => {
options.transaction = t
return ProductUpload.findAll({
where: {
handle: handle,
upload_id: uploadId
},
order: [['id','ASC']],
// transaction: options.transaction || null
})
.then(products => {
// Remove Upload Attributes
products = products.map(product => {
return _.omit(product.get({plain: true}), ['id', 'upload_id', 'created_at', 'updated_at'])
})
// Handle associations
products = products.map(product => {
if (product.associations) {
product.associations.forEach((a, index) => {
const association = {
upload_id: uploadId,
product_handle: product.handle || null,
product_sku: product.sku || null,
associated_product_handle: a.handle || null,
associated_product_sku: a.sku || null,
position: index + 1
}
associations.push(association)
})
}
delete product.associations
return product
})
// Sort the products to find the default if they got out of order somewhere
if (!products[0].title || products[0].title === '') {
products = products.sort((a, b) => {
if (a.title > b.title) {
return 1
}
else if (a.title < b.title) {
return 0
}
else {
return -1
}
})
}
// Construct Root Product
const defaultProduct = products.shift()
if (!defaultProduct) {
throw new Error(`${handle}: Default Product could not be established`)
}
if (!defaultProduct.handle) {
throw new Error(`${handle}: Default Product could not be established, missing handle`)
}
if (!defaultProduct.title) {
throw new Error(`${handle}: Default Product could not be established, missing title`)
}
// Add Product Variants
defaultProduct.variants = products.map(variant => {
if (!variant) {
throw new Error(`${handle}: Missing`)
}
else if (!variant.handle) {
throw new Error(`${handle}: Missing Handle`)
}
// Sku is required for a variant
else if (!variant.sku) {
throw new Error(`${handle}: Missing Variant SKU`)
}
else {
// Set the Images on the Variant
variant.images = variant.variant_images
return _.omit(variant, ['variant_images'])
}
})
// Remove Undefined
defaultProduct.variants = defaultProduct.variants.filter(variant => variant)
// Add the product with it's variants
return this.app.services.ProductService.addProduct(defaultProduct, {transaction: options.transaction || null})
.then(createdProduct => {
if (!createdProduct) {
throw new Error(`${handle} was not created`)
}
productsCount = 1
if (createdProduct.variants) {
variantsCount = createdProduct.variants.length
}
return
})
.catch(err => {
this.app.log.error(err)
errorsCount++
errors.push(`${handle}: ${err.message}`)
return
})
})
.then(() => {
return AssociationUpload.bulkCreate(associations)
.then(uploadedAssociations => {
associationsCount = uploadedAssociations ? uploadedAssociations.length : 0
return
})
.catch(err => {
errorsCount++
errors.push(err.message)
return
})
})
.then(uploadedAssociations => {
return {
products: productsCount,
variants: variantsCount,
associations: associationsCount,
errors_count: errorsCount,
errors: errors
}
})
.catch(err => {
errorsCount++
errors.push(err.message)
return {
products: productsCount,
variants: variantsCount,
associations: associationsCount,
errors_count: errorsCount,
errors: errors
}
})
})
}
/**
*
* @param file
* @returns {Promise}
*/
productMetaCsv(file) {
console.time('csv')
const uploadID = shortid.generate()
const ProxyEngineService = this.app.services.ProxyEngineService
const errors = []
let errorsCount = 0, lineNumber = 1
return new Promise((resolve, reject) => {
const options = {
header: true,
dynamicTyping: true,
encoding: 'utf-8',
step: (results, parser) => {
parser.pause()
lineNumber++
return this.csvProductMetaRow(results.data[0], uploadID)
.then(row => {
parser.resume()
})
.catch(err => {
errorsCount++
errors.push(`Line ${lineNumber}: ${err.message}`)
this.app.log.error('ROW ERROR', err)
parser.resume()
})
},
complete: (results, file) => {
console.timeEnd('csv')
results.upload_id = uploadID
ProxyEngineService.count('ProductMetaUpload', { where: { upload_id: uploadID }})
.then(count => {
results.products = count
results.errors = errors
results.errors_count = errorsCount
// Publish the event
ProxyEngineService.publish('product_meta_upload.complete', results)
return resolve(results)
})
.catch(err => {
errorsCount++
errors.push(err.message)
results.errors = errors
results.errors_count = errorsCount
return resolve(results)
})
},
error: (err, file) => {
reject(err)
}
}
const fileString = fs.readFileSync(file, 'utf8')
// Parse the CSV/TSV
csvParser.parse(fileString, options)
})
}
/**
*
* @param row
* @param uploadID
*/
csvProductMetaRow(row, uploadID) {
// Wrap this in a promise so we can gracefully handle an error
return Promise.resolve()
.then(() => {
const ProductMetaUpload = this.app.orm.ProductMetaUpload
const values = _.values(PRODUCT_META_UPLOAD)
const keys = _.keys(PRODUCT_META_UPLOAD)
const upload = {
upload_id: uploadID,
data: {}
}
_.each(row, (data, key) => {
if (typeof(data) === 'undefined' || data === '') {
row[key] = null
}
})
row = _.omitBy(row, _.isNil)
if (_.isEmpty(row)) {
return Promise.resolve({})
}
_.each(row, (data, key) => {
if (typeof(data) !== 'undefined' && data !== null && data !== '') {
const i = values.indexOf(key.replace(/^\s+|\s+$/g, ''))
const k = keys[i]
if (i > -1 && k) {
if (k === 'handle') {
// Can be a Product handle or a Product Handle with SKU
upload[k] = this.app.services.ProxyCartService.splitHandle(data)
}
else {
upload[k] = data
}
}
else {
let formatted = data
if (this.app.services.ProxyCartService.isJson(formatted)) {
formatted = JSON.parse(formatted)
}
upload.data[key] = formatted
}
}
})
return ProductMetaUpload.build(upload).save()
})
}
/**
*
* @param uploadId
* @returns {Promise}
*/
processProductMetaUpload(uploadId, options) {
options = options || {}
const ProductMetaUpload = this.app.orm.ProductMetaUpload
const Metadata = this.app.orm.Metadata
const Product = this.app.orm.Product
const ProductVariant = this.app.orm.ProductVariant
const errors = []
let productsTotal = 0, errorsCount = 0
return ProductMetaUpload.batch({
where: {
upload_id: uploadId
}
}, metadatums => {
const Sequelize = this.app.orm.Product.sequelize
return Sequelize.Promise.mapSeries(metadatums, metadata => {
const Type = metadata.handle.indexOf(':') === -1 ? Product : ProductVariant
let where = {}
const includes = [
{
model: Metadata,
as: 'metadata',
attributes: ['data', 'id']
}
]
if (Type === Product) {
where = {
'handle': metadata.handle
}
}
else if (Type === ProductVariant){
const product = metadata.handle.split(/:(.+)/)
where = {
'sku': this.app.services.ProxyCartService.sku(product[1]),
'$Product.handle$': this.app.services.ProxyCartService.handle(product[0])
}
includes.push({
model: Product,
attributes: ['handle']
})
}
else {
const err = new Error(`Target ${metadata.handle} not a Product or a Variant`)
errorsCount++
errors.push(err.message)
return
}
return Type.findOne(
{
where: where,
attributes: ['id'],
include: includes,
transaction: options.transaction || null
})
.then(target => {
if (!target) {
const err = new Error(`Target ${Type.getTableName()} ${metadata.handle} not found`)
errorsCount++
errors.push(err.message)
return
}
if (target.metadata) {
target.metadata.data = metadata.data
return target.metadata.save({transaction: options.transaction || null})
.then(() => {
productsTotal++
return
})
.catch(err => {
errorsCount++
errors.push(`${metadata.handle}: ${err.message}`)
return
})
}
else {
return target.createMetadata({data: metadata.data}, {transaction: options.transaction || null})
.then(() => {
productsTotal++
return
})
.catch(err => {
errorsCount++
errors.push(`${metadata.handle}: ${err.message}`)
return
})
}
})
.catch(err => {
errorsCount++
errors.push(err.message)
return
})
})
})
.then(results => {
return ProductMetaUpload.destroy({
where: {upload_id: uploadId }
})
.catch(err => {
errorsCount++
errors.push(err.message)
return
})
})
.then(destroyed => {
const results = {
upload_id: uploadId,
products: productsTotal,
errors_count: errorsCount,
errors: errors
}
this.app.services.ProxyEngineService.publish('product_metadata_process.complete', results)
return results
})
}
/**
*
* @param uploadId
* @param options
* @returns {Promise}
*/
processProductAssociationUpload(uploadId, options) {
options = options || {}
const AssociationUpload = this.app.orm['ProductAssociationUpload']
const ProductService = this.app.services.ProductService
const errors = []
let associationsTotal = 0, errorsCount = 0
return AssociationUpload.batch({
where: {
upload_id: uploadId
},
transaction: options.transaction | null
}, associations => {
return AssociationUpload.sequelize.Promise.mapSeries(associations, association => {
return ProductService.addAssociation({
handle: association.product_handle,
sku: association.product_sku
}, {
handle: association.associated_product_handle,
sku: association.associated_product_sku,
position: association.position
}, {
transaction: options.transaction || null
})
.then(() => {
associationsTotal++
return
})
.catch(err => {
errorsCount++
errors.push(`${association.product_handle} -> ${association.associated_product_handle}: ${err.message}`)
return
})
})
})
.then(results => {
return AssociationUpload.destroy({
where: {
upload_id: uploadId
},
transaction: options.transaction || null
})
.catch(err => {
errorsCount++
errors.push(err)
return
})
})
.then(destroyed => {
const results = {
upload_id: uploadId,
associations: associationsTotal,
errors_count: errorsCount,
errors: errors
}
this.app.services.ProxyEngineService.publish('product_associations_process.complete', results)
return results
})
}
}