Source: order.js

/**
 * The Order module
 * @module module
 * @copyright CHECKROOM NV 2015
 */
define([
    "jquery",
    "api",
    "transaction",
    "conflict",
    "common"], /** @lends Transaction */  function ($, api, Transaction, Conflict, common) {

    // Allow overriding the ctor during inheritance
    // http://stackoverflow.com/questions/4152931/javascript-inheritance-call-super-constructor-or-use-prototype-chain
    var tmp = function() {};
    tmp.prototype = Transaction.prototype;

    /**
     * @name Order
     * @class Order
     * @constructor
     * @extends Transaction
     */
    var Order = function(opt) {
        var spec = $.extend({
            crtype: "cheqroom.types.order",
            _fields: ["*"]
        }, opt);
        Transaction.call(this, spec);

        this.dsReservations = spec.dsReservations;
    };

    Order.prototype = new tmp();
    Order.prototype.constructor = Order;

    //
    // Date helpers; we'll need these for sliding from / to dates during a long user session
    //
    // getMinDateFrom (overwritten)
    // getMaxDateFrom (default)
    // getMinDateDue (default, same as getMinDateTo)
    // getMaxDateDue (default, same as getMinDateTo)

    /**
     * Overwrite min date for order so it is rounded by default
     * Although it's really the server who sets the actual date
     * While an order is creating, we'll always overwrite its from date
     */
    Order.prototype.getMinDateFrom = function() {
        return this.getNowRounded();
    };

    /**
     * Overwrite how the Order.due min date works
     * We want "open" orders to be set due at least 1 timeslot from now
     */
    Order.prototype.getMinDateDue = function() {
        if (this.status=="open") {
            // Open orders can set their date to be due
            // at least 1 timeslot from now,
            // we can just call the default getMinDateTo function
            return this.getNextTimeSlot();
        } else {
            return Transaction.prototype.getMinDateDue.call(this);
        }
    };

    //
    // Document overrides
    //
    Order.prototype._toJson = function(options) {
        // Should only be used during create
        // and never be called on order update
        // since most updates are done via setter methods
        var data = Transaction.prototype._toJson.call(this, options);
        data.fromDate = (this.fromDate!=null) ? this.fromDate.toJSONDate() : "null";
        data.toDate = (this.toDate!=null) ? this.toDate.toJSONDate() : "null";
        data.due = (this.due!=null) ? this.due.toJSONDate() : "null";
        return data;
    };

    Order.prototype._fromJson = function(data, options) {
        var that = this;

        // Already set the from, to and due dates
        // Transaction._fromJson might need it during _getConflicts
        that.from = ((data.started==null) || (data.started=="null")) ? null : data.started;
        that.to = ((data.finished==null) || (data.finished=="null")) ? null : data.finished;
        that.due = ((data.due==null) || (data.due=="null")) ? null: data.due;
        that.reservation = data.reservation || null;

        return Transaction.prototype._fromJson.call(this, data, options)
            .then(function() {
                $.publish("order.fromJson", data);
                return data;
            });
    };

    //
    // Helpers
    //
    /**
     * Gets a moment duration object
     * @method
     * @name Order#getDuration
     * @returns {duration}
     */
    Order.prototype.getDuration = function() {
        return common.getOrderDuration(this.raw);
    };

    /**
     * Gets a friendly order duration or empty string
     * @method
     * @name Order#getFriendlyDuration
     * @returns {string}
     */
    Order.prototype.getFriendlyDuration = function() {
        return common.getFriendlyOrderDuration(this.raw, this._getDateHelper());
    };

    /**
     * Checks if a PDF document can be generated
     * @method
     * @name Order#canGenerateAgreement
     * @returns {boolean}
     */
    Order.prototype.canGenerateAgreement = function() {
        return (this.status=="open") || (this.status=="closed");
    };

    /**
     * Checks if order can be checked in
     * @method
     * @name Order#canCheckin
     * @returns {boolean}
     */
    Order.prototype.canCheckin = function() {
        return (this.status=="open");
    };

    /**
     * Checks if the order has an reservation linked to it
     * @method
     * @name Order#canGoToReservation
     * @returns {boolean}
     */
    Order.prototype.canGoToReservation = function(){
        return this.reservation != null;
    }

    /**
     * Checks if due date is valid for an creating order
     * oterwise return true
     *
     * @name Order#isValidDueDate
     * @return {Boolean} 
     */
    Order.prototype.isValidDueDate = function(){
        var due = this.due,
            status = this.status,
            nextTimeSlot = this.getNextTimeSlot(),
            maxDueDate = this.getMaxDateDue();

        if(status == "creating" || status == "open"){
            return due!=null && (due.isSame(nextTimeSlot) || due.isAfter(nextTimeSlot));
        }

        return true;
    }

    /**
     * Checks if order can be checked out
     * @method
     * @name Order#canCheckout
     * @returns {boolean}
     */
    Order.prototype.canCheckout = function() {
        var that = this;
        return (
            (this.status=="creating") &&
            (this.location!=null) &&
            ((this.contact!=null) &&
            (this.contact.status == "active")) &&
            (this.isValidDueDate()) &&
            ((this.items) &&
            (this.items.length > 0) &&
            (this.items.filter(function(item){ return that.id == that.helper.ensureId(item.order); }).length > 0))
        );
    };

    /**
     * Checks if order can undo checkout
     * @method
     * @name Order#canUndoCheckout
     * @returns {boolean}
     */
    Order.prototype.canUndoCheckout = function() {
        return (this.status=="open");
    };

    /**
     * Checks if the order can be deleted (based on status)
     * @method
     * @name Order#canDelete
     * @returns {boolean}
     */
    Order.prototype.canDelete = function() {
        return (this.status=="creating");
    };

    /**
     * Checks if items can be added to the checkout (based on status)
     * @method
     * @name Order#canAddItems
     * @returns {boolean}
     */
    Order.prototype.canAddItems = function() {
        return (this.status=="creating");
    };

    /**
     * Checks if items can be removed from the checkout (based on status)
     * @method
     * @name Order#canRemoveItems
     * @returns {boolean}
     */
    Order.prototype.canRemoveItems = function() {
        return (this.status=="creating");
    };

    /**
     * Checks if items can be swapped in the checkout (based on status)
     * @method
     * @name Order#canSwapItems
     * @returns {boolean}
     */
    Order.prototype.canSwapItems = function() {
        return (this.status=="creating");
    };

    /**
     * Checks if we can generate a document for this order (based on status)
     * @name Order#canGenerateDocument
     * @returns {boolean}
     */
    Order.prototype.canGenerateDocument = function() {
        return (this.status=="open") || (this.status=="closed");
    };

    //
    // Base overrides
    //

    //
    // Transaction overrides
    //
    Order.prototype._getConflictsForExtend = function() {
        var conflicts = [];

        // Only orders which are incomplete,
        // but have items and / or due date can have conflicts
        if (this.status=="open") {

            // Only check for new conflicts on the items
            // that are still checked out under this order
            var items = [],
                that = this;
            $.each(this.items, function(i, item) {
                if( (item.status == "checkedout") &&
                    (item.order == that.id)) {
                    items.push(item);
                }
            });

            // If we have a due date,
            // check if it conflicts with any reservations
            if (this.due) {
                return this._getServerConflicts(
                    this.items,
                    this.from,
                    this.due,
                    this.id,  // orderId
                    this.helper.ensureId(this.reservation))  // reservationId
                    .then(function(serverConflicts) {
                        return conflicts.concat(serverConflicts);
                    });
            }
        }

        return $.Deferred().resolve(conflicts);
    };

    /**
     * Gets a list of Conflict objects
     * used during Transaction._fromJson
     * @returns {promise}
     * @private
     */
    Order.prototype._getConflicts = function() {
        var conflicts = [];

        // Only orders which are incomplete,
        // but have items and / or due date can have conflicts
        if( (this.status=="creating") &&
            (this.items.length>0)) {

            // Get some conflicts we can already calculate on the client side
            conflicts = this._getClientConflicts();

            // If we have a due date,
            // check if it conflicts with any reservations
            if (this.due) {
                return this._getServerConflicts(
                    this.items,
                    this.from,
                    this.due,
                    this.id,  // orderId
                    this.helper.ensureId(this.reservation))  // reservationId
                    .then(function(serverConflicts) {
                        return conflicts.concat(serverConflicts);
                    });
            }
        }

        return $.Deferred().resolve(conflicts);
    };

    /**
     * Get server side conflicts for items between two dates
     * Also pass extra info like own order and reservation
     * so we can avoid listing conflicts with ourselves
     * @param items array of item objects (not just the ids)
     * @param fromDate
     * @param dueDate
     * @param orderId
     * @param reservationId
     * @returns {*}
     * @private
     */
    Order.prototype._getServerConflicts = function(items, fromDate, dueDate, orderId, reservationId) {
        var conflicts = [],
            kind = "",
            transItem = null,
            itemIds = common.getItemIds(items);

        // Get the availabilities for these items
        return this.dsItems.call(null, "getAvailabilities", {
            items: itemIds,
            fromDate: fromDate,
            toDate: dueDate})
            .then(function(data) {

                // Run over unavailabilties for these items
                $.each(data, function(i, av) {

                    // Find back the more complete item object via the `items` param
                    // It has useful info like item.name we can use in the conflict message
                    // $.grep returns an array with 1 item,
                    // we need reference to the 1st item for transItem
                    transItem = $.grep(items, function(item) { return item._id == av.item});
                    if( (transItem) &&
                        (transItem.length > 0)) {
                        transItem = transItem[0];
                    }

                    if( (transItem!=null) &&
                        (transItem.status!="expired")) {

                        // Order cannot conflict with itself
                        // or with the Reservation from which it was created
                        if( (av.order != orderId) &&
                            (av.reservation != reservationId)) {
                            kind = "";
                            kind = kind || ((av.order) ? "order" : "");
                            kind = kind || ((av.reservation) ? "reservation" : "");

                            conflicts.push(new Conflict({
                                kind: kind,
                                item: transItem._id,
                                itemName: transItem.name,
                                fromDate: av.fromDate,
                                toDate: av.toDate,
                                doc: av.order || av.reservation
                            }));
                        }
                    }
                });

                return conflicts;
            });
    };

    Order.prototype._getClientConflicts = function() {
        // Some conflicts can be checked already on the client
        // We can check if all the items are:
        // - at the right location
        // - not expired
        var conflicts = [],
            locId = this.helper.ensureId(this.location || "");

        $.each(this.items, function(i, item) {
            if (item.status == "expired") {
                conflicts.push(new Conflict({
                    kind: "expired",
                    item: item._id,
                    itemName: item.name,
                    locationCurrent: item.location,
                    locationDesired: locId
                }));
                // If order location is defined, check if item
                // is at the right location
            } else if (locId && item.location != locId) {
                conflicts.push(new Conflict({
                    kind: "location",
                    item: item._id,
                    itemName: item.name,
                    locationCurrent: item.location,
                    locationDesired: locId
                }));
            }
        });

        return conflicts;
    };

    /**
     * Sets the Order from and due date in a single call
     * _checkFromDueDate will handle the special check for when the order is open
     * @method
     * @name Order#setFromDueDate
     * @param from
     * @param due (optional) if null, we'll take the default average checkout duration as due date
     * @param skipRead
     * @returns {promise}
     */
    Order.prototype.setFromDueDate = function(from, due, skipRead) {
        if (this.status!="creating") {
            return $.Deferred().reject(new api.ApiUnprocessableEntity("Cannot set order from / due date, status is "+this.status));
        }

        var that = this;
        var roundedFromDate = this.getMinDateFrom();
        var roundedDueDate = (due) ?
            this._getDateHelper().roundTimeTo(due) :
            this._getDateHelper().addAverageDuration(roundedFromDate);

        return this._checkFromDueDate(roundedFromDate, roundedDueDate)
            .then(function() {
                that.from = roundedFromDate;
                that.due = roundedDueDate;
                return that._handleTransaction(skipRead);
            });
    };

    /**
     * Sets the Order from date
     * @method
     * @name Order#setFromDate
     * @param date
     * @param skipRead
     * @returns {promise}
     */
    Order.prototype.setFromDate = function(date, skipRead) {
        if (this.status!="creating") {
            return $.Deferred().reject(new api.ApiUnprocessableEntity("Cannot set order from date, status is "+this.status));
        }

        var that = this;

        var roundedFromDate = this._getDateHelper().roundTimeFrom(date);

        return this._checkFromDueDate(roundedFromDate, this.due)
            .then(function() {
                that.from = roundedFromDate;
                return that._handleTransaction(skipRead);
            });
    };

    /**
     * Clears the order from date
     * @method
     * @name Order#clearFromDate
     * @param skipRead
     * @returns {promise}
     */
    Order.prototype.clearFromDate = function(skipRead) {
        if (this.status!="creating") {
            return $.Deferred().reject(new api.ApiUnprocessableEntity("Cannot clear order from date, status is "+this.status));
        }

        this.from = null;

        return this._handleTransaction(skipRead);
    };

    /**
     * Sets the order due date
     * _checkFromDueDate will handle the special check for when the order is open
     * @method
     * @name Order#setDueDate
     * @param due
     * @param skipRead
     * @returns {promise}
     */
    Order.prototype.setDueDate = function(due, skipRead) {
        // Cannot change the to-date of a reservation that is not in status "creating"
        if( (this.status!="creating") &&
            (this.status!="open")){
            return $.Deferred().reject(new api.ApiUnprocessableEntity("Cannot set order due date, status is "+this.status));
        }

        // The to date must be:
        // 1) at least 30 minutes into the future
        // 2) at least 15 minutes after the from date (if set)
        var that = this;
        var roundedDueDate = this._getDateHelper().roundTimeTo(due);

        this.from = this.getMinDateFrom();

        return this._checkDueDateBetweenMinMax(roundedDueDate)
            .then(function() {
                that.due = roundedDueDate;

                //If order doesn't exist yet, we set due date in create call
                //otherwise use setDueDate to update transaction
                if(!that.existsInDb()){
                    return that._createTransaction(skipRead);
                } else{
                    // If status is open when due date is changed, 
                    // we need to check for conflicts
                    if(that.status == "open"){
                        return that.canExtend(roundedDueDate).then(function(resp){
                            if(resp && resp.result == true){
                                return that.extend(roundedDueDate, skipRead);
                            }else{
                                return $.Deferred().reject("Cannot extend order to given date because it has conflicts.", resp);
                            }
                        })
                    }else{
                        return that._doApiCall({method: "setDueDate", params: {due: roundedDueDate}, skipRead: skipRead});
                    }
                }
            });
    };

    /**
     * Clears the order due date
     * @method
     * @name Order#clearDueDate
     * @param skipRead
     * @returns {*}
     */
    Order.prototype.clearDueDate = function(skipRead) {
        if (this.status!="creating") {
            return $.Deferred().reject(new api.ApiUnprocessableEntity("Cannot clear order due date, status is "+this.status));
        }

        this.due = null;
        return this._doApiCall({method: "clearDueDate", skipRead: skipRead});
    };

    Order.prototype.setToDate = function(date, skipRead) {
        throw "Order.setToDate not implemented, it is set during order close";
    };

    Order.prototype.clearToDate = function(date, skipRead) {
        throw "Order.clearToDate not implemented, it is set during order close";
    };

    //
    // Business logic calls
    //
    /**
     * Searches for items that could match this order
     * @method
     * @name Order#searchItems
     * @param params
     * @param useAvailabilies
     * @param onlyUnbooked
     * @param skipItems
     * @returns {promise}
     */
    Order.prototype.searchItems = function(params, useAvailabilies, onlyUnbooked, skipItems, listName) {
        return this._searchItems(params, listName != null?listName:"available", useAvailabilies, onlyUnbooked, skipItems || this.items);
    };

    /**
     * Checks in the order
     * @method
     * @name Order#checkin
     * @param itemIds
     * @param location
     * @param skipRead
     * @param skipErrorHandling
     * @returns {promise}
     */
    Order.prototype.checkin = function(itemIds, location, skipRead, skipErrorHandling) {
        var that = this;
        return this._doApiCall({method: "checkin", params: {items: itemIds, location: location}, skipRead: skipRead})
            .then(function(resp){ 
                return resp; 
            },function(err){
                if(!skipErrorHandling){
                    if( (err) && 
                        (err.code == 422)){
                        if(err.opt && err.opt.detail.indexOf('order has status closed') != -1){
                            return that.get();
                        }else if(err.opt && err.opt.detail.indexOf('already checked in or used somewhere else') != -1){ 
                            return that.get();
                        }
                    }
                }

                //IMPORTANT
                //Need to return a new deferred reject because otherwise
                //done would be triggered in parent deferred
                return $.Deferred().reject(err);
            });
    };

    /**
     * Checks out the order
     * @method
     * @name Order#checkout
     * @param skipRead
     * @param skipErrorHandling
     * @returns {promise}
     */
    Order.prototype.checkout = function(skipRead, skipErrorHandling) {
        var that = this;
        return this._doApiCall({method: "checkout", skipRead: skipRead})
            .then(function(resp){ 
                return resp; 
            },function(err){
                if(!skipErrorHandling){
                    if( (err) && 
                        (err.code == 422)){
                        if(err.opt && err.opt.detail.indexOf('order has status open') != -1){
                            return that.get();
                        }
                    }
                }

                //IMPORTANT
                //Need to return a new deferred reject because otherwise
                //done would be triggered in parent deferred
                return $.Deferred().reject(err);
            });
    };

    /**
     * Undoes the order checkout
     * @method
     * @name Order#undoCheckout
     * @param skipRead
     * @returns {promise}
     */
    Order.prototype.undoCheckout = function(skipRead, skipErrorHandling) {
        var that = this;
        return this._doApiCall({method: "undoCheckout", skipRead: skipRead})
            .then(function(resp){ 
                return resp; 
            },function(err){
                if(!skipErrorHandling){
                    if( (err) && 
                        (err.code == 422)){
                        if(err.opt && err.opt.detail.indexOf('order has status creating') != -1){
                            return that.get();
                        }
                    }
                }

                //IMPORTANT
                //Need to return a new deferred reject because otherwise
                //done would be triggered in parent deferred
                return $.Deferred().reject(err);
            });
    };

    /**
     * Checks of order due date can be extended to given date
     * @param  {moment} due
     * @param  {bool} skipRead
     * @return {promise}
     */
    Order.prototype.canExtend = function(due) {
        // We can only extend orders which are open
        // and for which their due date will be
        // at least 1 timeslot from now
        var can = true;
        if( (this.status!="open") ||
            (due.isBefore(this.getNextTimeSlot()))) {
            can = false;
        }

        return $.Deferred().resolve({ result: can });
    };

    /**
     * Extends order due date
     * @param  {moment} due
     * @param  {bool} skipRead
     * @return {promise}
     */
    Order.prototype.extend = function(due, skipRead){
        var that = this;

        return this.canExtend(due).then(function (resp) {
            if (resp && resp.result == true) {
              return that._doApiCall({ method: "extend", params: { due: due }, skipRead: skipRead });
            } else {
              return $.Deferred().reject('Cannot extend order to given date because it has conflicts.', resp);
            }
        }); 
    };

    /**
     * Generates a PDF document for the order
     * @method
     * @name Order#generateDocument
     * @param {string} template id
     * @param {string} signature (base64)
     * @param {bool} skipRead
     * @returns {promise}
     */
    Order.prototype.generateDocument = function(template, signature, skipRead) {
        return this._doApiLongCall({method: "generateDocument", params: {template: template, signature: signature}, skipRead: skipRead});
    };

    /**
     * Override _fromCommentsJson to also include linked reservation comments 
     * @param data
     * @param options
     * @returns {*}
     * @private
     */
    Order.prototype._fromCommentsJson = function(data, options) {
        var that = this;

        // Also parse reservation comments?
        if (that.dsReservations && data.reservation && data.reservation.comments && data.reservation.comments.length > 0) {
          // Parse Reservation keyValues
          return Base.prototype._fromCommentsJson.call(that, data.reservation, $.extend(options, { ds: that.dsReservations, fromReservation: true })).then(function () {
            var reservationComments = that.comments;
            return Base.prototype._fromCommentsJson.call(that, data, options).then(function () {
              // Add reservation comments
              that.comments = that.comments.concat(reservationComments).sort(function (a, b) {
                return b.modified > a.modified;
              });
            });
          });
        }

        // Use Default comments parser
        return Base.prototype._fromCommentsJson.call(that, data, options);
    };

    /**
     * Override _fromAttachmentsJson to also include linked reservation attachments
     * @param data
     * @param options
     * @returns {*}
     * @private
     */
    Order.prototype._fromAttachmentsJson = function(data, options) {
        var that = this;

        // Also parse reservation comments?
        if (that.dsReservations && data.reservation && data.reservation.comments && data.reservation.comments.length > 0) {
          // Parse Reservation keyValues
          return Base.prototype._fromAttachmentsJson.call(that, data.reservation, $.extend(options, { ds: that.dsReservations, fromReservation: true })).then(function () {
            var reservationAttachments = that.attachments;
            return Base.prototype._fromAttachmentsJson.call(that, data, options).then(function () {
              // Add reservation attachments
              that.attachments = that.attachments.concat(reservationAttachments).sort(function (a, b) {
                return b.modified > a.modified;
              });
            });
          });
        }

        // Use Default attachments parser
        return Base.prototype._fromAttachmentsJson.call(that, data, options);
    };

    //
    // Implementation
    //
    Order.prototype._checkFromDueDate = function(from, due) {
        var dateHelper = this._getDateHelper();
        var roundedFromDate = from; //(from) ? this._getHelper().roundTimeFrom(from) : null;
        var roundedDueDate = due; //(due) ? this._getHelper().roundTimeTo(due) : null;

        if (roundedFromDate && roundedDueDate) {
            return $.when(
                this._checkDateBetweenMinMax(roundedFromDate),
                this._checkDateBetweenMinMax(roundedDueDate)
            )
                .then(function(fromRes, dueRes) {
                    var interval = dateHelper.roundMinutes;
                    if (roundedDueDate.diff(roundedFromDate, "minutes") < interval) {
                        return $.Deferred().reject(new api.ApiUnprocessableEntity("Cannot set order from date, after (or too close to) to date "+roundedDueDate.toJSONDate()));
                    }
                    if (roundedFromDate.diff(roundedDueDate, "minutes") > interval) {
                        return $.Deferred().reject(new api.ApiUnprocessableEntity("Cannot set order due date, before (or too close to) from date "+roundedFromDate.toJSONDate()));
                    }
                });
        } else if (roundedFromDate) {
            return this._checkDateBetweenMinMax(roundedFromDate);
        } else if (roundedDueDate) {
            return this._checkDateBetweenMinMax(roundedDueDate);
        } else {
            return $.Deferred().reject(new api.ApiUnprocessableEntity("Cannot from/due date, both are null"));
        }
    };

    return Order;

});