6-mils
Version:
A JS library for sending, receiving, and parsing cXML messages.
382 lines (300 loc) • 12.8 kB
JavaScript
const debug = require('debug')('6-mils:OrderRequest')
const DateTime = require('luxon').DateTime
const isPlainObject = require('is-plain-obj')
const merge = require('lodash.merge')
const OutboundMessage = require('@6-mils/OutboundCxmlMessage')
const OrderResponse = require('./OrderResponse.js')
/**
* A collection of private property values for each instance of this class.
* @type {WeakMap}
*/
const _private = new WeakMap()
class OrderRequest extends OutboundMessage {
/**
* Options:
*
* {String} orderId The unique identifier for the purchase order
* represented by this cXML message.
*
* {String?} orderDate
*/
constructor (options) {
options = options || {}
options.orderId = options.orderId || ''
options.orderDate = options.orderDate || new Date()
debug('Constructing new message from options %o', options)
// Validate option values
if (!options.orderId) {
throw new Error('The "orderId" property of the "options" parameter is required and must not be blank.')
}
super(OutboundMessage.MESSAGE_TYPES.OrderRequest, options)
const props = {
language: 'en',
id: options.orderId.toString(),
date: (typeof options.orderDate === 'string' ? options.orderDate : DateTime.fromJSDate(options.orderDate).toString()),
orderType: 'regular',
requestType: 'new',
items: []
}
_private.set(this, props)
}
get orderId () {
return _private.get(this).id
}
get orderDate () {
return _private.get(this).date
}
get orderType () {
return _private.get(this).orderType
}
get requestType () {
return _private.get(this).requestType
}
/**
* Adds a line item to this purchase order.
*
* @param {Object} item A plain object containing the keys "name",
* "quantity", "supplierPartId", "unitPrice", and
* "uom".
*
* @return {undefined}
*/
addItem (item) {
item = item || {}
// Validate input
if (item.name == null) {
throw new Error('When adding an item to the order, "name" is a required property for "item".')
}
if (item.quantity == null || isNaN(item.quantity * 1)) {
throw new Error('When adding an item to the order, "quantity" is a required property for "item", and must be numeric.')
}
if (item.supplierPartId == null) {
throw new Error('When adding an item to the order, "supplierPartId" is a required property for "item".')
}
if (item.unitPrice == null || isNaN(item.unitPrice * 1)) {
throw new Error('When adding an item to the order, "unitPrice" is a required property for "item", and must be numeric.')
}
if (!item.currency) {
throw new Error('When adding an item to the order, "currency" is a required property for "item", and must not be blank.')
}
if (item.uom == null) {
throw new Error('When adding an item to the order, "uom" is a required property for "item".')
}
if (item.classification == null) {
throw new Error('When adding an item to the order, "classification" is a required property for "item", and must not be empty.')
} else if (!isPlainObject(item.classification) || Object.keys(item.classification).length === 0) {
throw new Error('When adding an item to the order, "classification" is a required property for "item", and must not be empty.')
}
const props = _private.get(this)
const lineNumber = props.items.length + 1
debug('Adding item to order #%s (line %d): %o', props.id, lineNumber, item)
props.items.push(merge({ lineNumber: lineNumber }, item))
_private.set(this, props)
}
/**
* Adds multiple line items to this purchase order.
*
* @param {Array} items A list of items following the same requirements
* for "addItem".
*
* @return this
*/
addItems (items) {
if (!Array.isArray(items)) {
throw new Error('The "items" parameter is required and must be an instance of Array.')
}
items.forEach((item) => this.addItem(item))
return this
}
/**
* Sets the bill-to address, purchasing card info, and tax.
*
* @param {Object} options
*
* @return this
*/
setBillingInfo (options) {
options = options || {}
const props = _private.get(this)
props.billTo = props.billTo || {}
props.order = props.order || {}
// Input validation
if (options.address == null) {
throw new Error('The "options" parameter is required and must at least contain the "address" property.')
}
if (!options.address.companyName) {
throw new Error('The bill-to address must at least contain the "companyName" property, which must not be blank.')
}
props.billTo.address = merge({}, options.address) // this avoids any chance of modifying the input value
if (options.email) {
if (!options.email.address) {
throw new Error('The bill-to e-mail must at least contain the "address" property, which must not be blank.')
}
props.billTo.email = merge({ nickname: 'default' }, options.email)
}
if (options.phone) {
if (!options.phone.countryCode || !options.phone.areaOrCityCode || !options.phone.number) {
throw new Error('The bill-to phone must at least contain the "countryCode", "areaOrCityCode", and "number" properties, which must not be blank.')
}
props.billTo.phone = merge({ nickname: 'default' }, options.phone) // this avoids any chance of modifying the input value
}
if (options.pcard) {
props.order.pcard = {}
if (!options.pcard.number || !options.pcard.expiration) {
throw new Error('The bill-to purchasing card must contain the "number" and "expiration" properties, which must not be blank. "expiration" must be either a string in ISO 8601 format, or an instance of {Date}.')
}
props.order.pcard.acct = options.pcard.number
if (typeof options.pcard.expiration !== 'string' && !(options.pcard.expiration instanceof Date)) {
throw new Error('The bill-to purchasing card must contain the "number" and "expiration" properties, which must not be blank. "expiration" must be either a string in ISO 8601 format, or an instance of {Date}.')
}
if (typeof options.pcard.expiration === 'string') {
const parsedDate = DateTime.fromISO(options.pcard.expiration)
if (!parsedDate.isValid) {
throw new Error('The bill-to purchasing card must contain the "number" and "expiration" properties, which must not be blank. "expiration" must be either a string in ISO 8601 format, or an instance of {Date}.')
}
props.order.pcard.exp = parsedDate.endOf('month').toString().substring(0, 10)
} else {
props.order.pcard.exp = DateTime.fromJSDate(options.pcard.expiration).endOf('month').toString().substring(0, 10)
}
}
if (options.tax) {
if (options.tax.amount == null || isNaN(options.tax.amount * 1)) {
throw new Error('The bill-to tax information must contain the "amount" property, which must have a numeric value.')
}
if (!options.tax.currency) {
throw new Error('The bill-to tax information must contain the "currency" property, which must not be blank.')
}
props.order.tax = merge({}, options.tax) // this avoids any chance of modifying the input value
}
_private.set(this, props)
}
/**
* Sets the ship-to address and shipping method.
*
* @param {Object} options
*
* @return this
*/
setShippingInfo (options) {
options = options || {}
const props = _private.get(this)
props.shipTo = props.shipTo || {}
props.order = props.order || {}
// Input validation
if (options.address == null) {
throw new Error('The "options" parameter is required and must at least contain the "address" property.')
}
if (!options.address.companyName || !options.address.attentionOf) {
throw new Error('The ship-to address must at least contain the "companyName" and "attentionOf" properties, which must not be blank.')
}
props.shipTo.address = merge({ attn: [], nickname: 'default' }, options.address) // this avoids any chance of modifying the input value
if (Array.isArray(options.address.attentionOf)) {
if (options.address.attentionOf.length === 0) {
throw new Error('The ship-to address must at least contain the "companyName" and "attentionOf" properties, which must not be blank.')
}
const summation = options.address.attentionOf.reduce((a, b) => { return a + b })
if (summation.length === 0) {
throw new Error('The ship-to address must at least contain the "companyName" and "attentionOf" properties, which must not be blank.')
}
options.address.attentionOf.forEach((line) => { props.shipTo.address.attn.push(line) })
} else {
props.shipTo.address.attn.push(options.address.attentionOf)
}
if (options.email) {
if (!options.email.address) {
throw new Error('The ship-to e-mail must at least contain the "address" property, which must not be blank.')
}
props.shipTo.email = merge({ nickname: 'default' }, options.email)
}
if (options.phone) {
if (!options.phone.countryCode || !options.phone.areaOrCityCode || !options.phone.number) {
throw new Error('The ship-to phone must at least contain the "countryCode", "areaOrCityCode", and "number" properties, which must not be blank.')
}
props.shipTo.phone = merge({ nickname: 'default' }, options.phone)
}
if (options.method) {
if (options.method.amount == null || isNaN(options.method.amount * 1)) {
throw new Error('The ship-to method must contain the "amount" property, which must have a numeric value.')
}
props.order.shipping = merge({}, options.method)
}
_private.set(this, props)
}
/**
* Sets the total cost of the order. Only necessary if the items are of mixed
* currencies (otherwise, the total will be calculated automatically).
*
* @param {Object} options
*
* @return this
*/
setTotal (options) {
options = options || {}
const props = _private.get(this)
// Input validation
if (options.amount == null || isNaN(options.amount * 1)) {
throw new Error('The total must contain the "amount" property, which must have a numeric value.')
}
if (!options.currency) {
throw new Error('The total must contain the "currency" property, which must not be blank.')
}
props.total = merge({}, options) // this avoids any chance of modifying the input value
_private.set(this, props)
}
/**
* Returns the raw cXML of the underlying OrderRequest message.
*
* @param {Object?} options An optional dictionary that may contain a
* single optional key: `format`. If that key
* has a truthy value, the output will be
* formatted to be more human-readable.
*
* @return {String}
*/
toString (options) {
options = (options || {})
return this._renderCxml(_private.get(this), options.format)
}
/**
* Submits the OrderReq to the supplier's site.
*
* @param {String} url The URL that the XML will be POSTed to. If the
* value '%%TEST%%' is provided, no actual HTTP
* request will take place.
*
* @return {Promise} Fulfilled with an instance of {OrderResponse}, or
* rejected if there is a problem with the underlying
* HTTP transmission.
*/
async submit (url) {
url = (url || '')
if (url.length === 0 || typeof url !== 'string') {
throw new Error('The "url" parameter is required and must not be a non-empty string.')
}
const props = _private.get(this)
props.total = props.total || {}
if (props.total.amount == null) {
let totalCost = 0
let currency = ''
props.items.forEach((item) => {
if (!currency) {
currency = item.currency
} else {
if (item.currency !== currency) {
throw new Error('Before submitting the order, "setTotal" must be called if all of the items in the order do not have the same currency.')
}
}
totalCost += (item.quantity * item.unitPrice)
})
props.total = {
amount: totalCost,
currency: currency
}
}
if (url === '%%TEST%%') {
return new OrderResponse(this._getGenericResponse())
}
return new OrderResponse(await this._submitCxml(url, props))
}
}
module.exports = OrderRequest