Вы находитесь на странице: 1из 120

Backbone JS

// Backbone.js 1.1.0

// (c) 2010-2011 Jeremy Ashkenas, DocumentCloud Inc.


// (c) 2011-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters &
Editors
// Backbone may be freely distributed under the MIT license.
// For all details and documentation:
// http://backbonejs.org

(function(){

// Initial Setup
// -------------

// Save a reference to the global object (`window` in the browser, `exports`


// on the server).
var root = this;

// Save the previous value of the `Backbone` variable, so that it can be


// restored later on, if `noConflict` is used.
var previousBackbone = root.Backbone;

// Create local references to array methods we'll want to use later.


var array = [];
var push = array.push;
var slice = array.slice;
var splice = array.splice;

// The top-level namespace. All public Backbone classes and modules will
// be attached to this. Exported for both the browser and the server.
var Backbone;
if (typeof exports !== 'undefined') {
Backbone = exports;
} else {
Backbone = root.Backbone = {};
}

// Current version of the library. Keep in sync with `package.json`.


Backbone.VERSION = '1.1.0';

// Require Underscore, if we're on the server, and it's not already present.
var _ = root._;
if (!_ && (typeof require !== 'undefined')) _ = require('underscore');

// For Backbone's purposes, jQuery, Zepto, Ender, or My Library (kidding) owns


// the `$` variable.
Backbone.$ = root.jQuery || root.Zepto || root.ender || root.$;

// Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable


// to its previous owner. Returns a reference to this Backbone object.
Backbone.noConflict = function() {
root.Backbone = previousBackbone;
return this;
};

// Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option


// will fake `"PATCH"`, `"PUT"` and `"DELETE"` requests via the `_method` parameter
and
// set a `X-Http-Method-Override` header.
Backbone.emulateHTTP = false;
// Turn on `emulateJSON` to support legacy servers that can't deal with direct
// `application/json` requests ... will encode the body as
// `application/x-www-form-urlencoded` instead and will send the model in a
// form param named `model`.
Backbone.emulateJSON = false;

// Backbone.Events
// ---------------

// A module that can be mixed in to *any object* in order to provide it with


// custom events. You may bind with `on` or remove with `off` callback
// functions to an event; `trigger`-ing an event fires all callbacks in
// succession.
//
// var object = {};
// _.extend(object, Backbone.Events);
// object.on('expand', function(){ alert('expanded'); });
// object.trigger('expand');
//
var Events = Backbone.Events = {

// Bind an event to a `callback` function. Passing `"all"` will bind


// the callback to all events fired.
on: function(name, callback, context) {
if (!eventsApi(this, 'on', name, [callback, context]) || !callback) return this;
this._events || (this._events = {});
var events = this._events[name] || (this._events[name] = []);
events.push({callback: callback, context: context, ctx: context || this});
return this;
},

// Bind an event to only be triggered a single time. After the first time
// the callback is invoked, it will be removed.
once: function(name, callback, context) {
if (!eventsApi(this, 'once', name, [callback, context]) || !callback) return
this;
var self = this;
var once = _.once(function() {
self.off(name, once);
callback.apply(this, arguments);
});
once._callback = callback;
return this.on(name, once, context);
},

// Remove one or many callbacks. If `context` is null, removes all


// callbacks with that function. If `callback` is null, removes all
// callbacks for the event. If `name` is null, removes all bound
// callbacks for all events.
off: function(name, callback, context) {
var retain, ev, events, names, i, l, j, k;
if (!this._events || !eventsApi(this, 'off', name, [callback, context])) return
this;
if (!name && !callback && !context) {
this._events = {};
return this;
}
names = name ? [name] : _.keys(this._events);
for (i = 0, l = names.length; i < l; i++) {
name = names[i];
if (events = this._events[name]) {
this._events[name] = retain = [];
if (callback || context) {
for (j = 0, k = events.length; j < k; j++) {
ev = events[j];
if ((callback && callback !== ev.callback && callback !==
ev.callback._callback) ||
(context && context !== ev.context)) {
retain.push(ev);
}
}
}
if (!retain.length) delete this._events[name];
}
}

return this;
},

// Trigger one or many events, firing all bound callbacks. Callbacks are
// passed the same arguments as `trigger` is, apart from the event name
// (unless you're listening on `"all"`, which will cause your callback to
// receive the true name of the event as the first argument).
trigger: function(name) {
if (!this._events) return this;
var args = slice.call(arguments, 1);
if (!eventsApi(this, 'trigger', name, args)) return this;
var events = this._events[name];
var allEvents = this._events.all;
if (events) triggerEvents(events, args);
if (allEvents) triggerEvents(allEvents, arguments);
return this;
},

// Tell this object to stop listening to either specific events ... or


// to every object it's currently listening to.
stopListening: function(obj, name, callback) {
var listeningTo = this._listeningTo;
if (!listeningTo) return this;
var remove = !name && !callback;
if (!callback && typeof name === 'object') callback = this;
if (obj) (listeningTo = {})[obj._listenId] = obj;
for (var id in listeningTo) {
obj = listeningTo[id];
obj.off(name, callback, this);
if (remove || _.isEmpty(obj._events)) delete this._listeningTo[id];
}
return this;
}

};

// Regular expression used to split event strings.


var eventSplitter = /\s+/;

// Implement fancy features of the Events API such as multiple event


// names `"change blur"` and jQuery-style event maps `{change: action}`
// in terms of the existing API.
var eventsApi = function(obj, action, name, rest) {
if (!name) return true;

// Handle event maps.


if (typeof name === 'object') {
for (var key in name) {
obj[action].apply(obj, [key, name[key]].concat(rest));
}
return false;
}

// Handle space separated event names.


if (eventSplitter.test(name)) {
var names = name.split(eventSplitter);
for (var i = 0, l = names.length; i < l; i++) {
obj[action].apply(obj, [names[i]].concat(rest));
}
return false;
}

return true;
};

// A difficult-to-believe, but optimized internal dispatch function for


// triggering events. Tries to keep the usual cases speedy (most internal
// Backbone events have 3 arguments).
var triggerEvents = function(events, args) {
var ev, i = -1, l = events.length, a1 = args[0], a2 = args[1], a3 = args[2];
switch (args.length) {
case 0: while (++i < l) (ev = events[i]).callback.call(ev.ctx); return;
case 1: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1); return;
case 2: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2); return;
case 3: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2, a3);
return;
default: while (++i < l) (ev = events[i]).callback.apply(ev.ctx, args);
}
};

var listenMethods = {listenTo: 'on', listenToOnce: 'once'};

// Inversion-of-control versions of `on` and `once`. Tell *this* object to


// listen to an event in another object ... keeping track of what it's
// listening to.
_.each(listenMethods, function(implementation, method) {
Events[method] = function(obj, name, callback) {
var listeningTo = this._listeningTo || (this._listeningTo = {});
var id = obj._listenId || (obj._listenId = _.uniqueId('l'));
listeningTo[id] = obj;
if (!callback && typeof name === 'object') callback = this;
obj[implementation](name, callback, this);
return this;
};
});

// Aliases for backwards compatibility.


Events.bind = Events.on;
Events.unbind = Events.off;

// Allow the `Backbone` object to serve as a global event bus, for folks who
// want global "pubsub" in a convenient place.
_.extend(Backbone, Events);

// Backbone.Model
// --------------

// Backbone **Models** are the basic data object in the framework --


// frequently representing a row in a table in a database on your server.
// A discrete chunk of data and a bunch of useful, related methods for
// performing computations and transformations on that data.
// Create a new model with the specified attributes. A client id (`cid`)
// is automatically generated and assigned for you.
var Model = Backbone.Model = function(attributes, options) {
var attrs = attributes || {};
options || (options = {});
this.cid = _.uniqueId('c');
this.attributes = {};
if (options.collection) this.collection = options.collection;
if (options.parse) attrs = this.parse(attrs, options) || {};
attrs = _.defaults({}, attrs, _.result(this, 'defaults'));
this.set(attrs, options);
this.changed = {};
this.initialize.apply(this, arguments);
};

// Attach all inheritable methods to the Model prototype.


_.extend(Model.prototype, Events, {

// A hash of attributes whose current and previous value differ.


changed: null,

// The value returned during the last failed validation.


validationError: null,

// The default name for the JSON `id` attribute is `"id"`. MongoDB and
// CouchDB users may want to set this to `"_id"`.
idAttribute: 'id',

// Initialize is an empty function by default. Override it with your own


// initialization logic.
initialize: function(){},

// Return a copy of the model's `attributes` object.


toJSON: function(options) {
return _.clone(this.attributes);
},

// Proxy `Backbone.sync` by default -- but override this if you need


// custom syncing semantics for *this* particular model.
sync: function() {
return Backbone.sync.apply(this, arguments);
},

// Get the value of an attribute.


get: function(attr) {
return this.attributes[attr];
},

// Get the HTML-escaped value of an attribute.


escape: function(attr) {
return _.escape(this.get(attr));
},

// Returns `true` if the attribute contains a value that is not null


// or undefined.
has: function(attr) {
return this.get(attr) != null;
},

// Set a hash of model attributes on the object, firing `"change"`. This is


// the core primitive operation of a model, updating the data and notifying
// anyone who needs to know about the change in state. The heart of the beast.
set: function(key, val, options) {
var attr, attrs, unset, changes, silent, changing, prev, current;
if (key == null) return this;

// Handle both `"key", value` and `{key: value}` -style arguments.


if (typeof key === 'object') {
attrs = key;
options = val;
} else {
(attrs = {})[key] = val;
}

options || (options = {});

// Run validation.
if (!this._validate(attrs, options)) return false;

// Extract attributes and options.


unset = options.unset;
silent = options.silent;
changes = [];
changing = this._changing;
this._changing = true;

if (!changing) {
this._previousAttributes = _.clone(this.attributes);
this.changed = {};
}
current = this.attributes, prev = this._previousAttributes;

// Check for changes of `id`.


if (this.idAttribute in attrs) this.id = attrs[this.idAttribute];

// For each `set` attribute, update or delete the current value.


for (attr in attrs) {
val = attrs[attr];
if (!_.isEqual(current[attr], val)) changes.push(attr);
if (!_.isEqual(prev[attr], val)) {
this.changed[attr] = val;
} else {
delete this.changed[attr];
}
unset ? delete current[attr] : current[attr] = val;
}

// Trigger all relevant attribute changes.


if (!silent) {
if (changes.length) this._pending = true;
for (var i = 0, l = changes.length; i < l; i++) {
this.trigger('change:' + changes[i], this, current[changes[i]], options);
}
}

// You might be wondering why there's a `while` loop here. Changes can
// be recursively nested within `"change"` events.
if (changing) return this;
if (!silent) {
while (this._pending) {
this._pending = false;
this.trigger('change', this, options);
}
}
this._pending = false;
this._changing = false;
return this;
},

// Remove an attribute from the model, firing `"change"`. `unset` is a noop


// if the attribute doesn't exist.
unset: function(attr, options) {
return this.set(attr, void 0, _.extend({}, options, {unset: true}));
},

// Clear all attributes on the model, firing `"change"`.


clear: function(options) {
var attrs = {};
for (var key in this.attributes) attrs[key] = void 0;
return this.set(attrs, _.extend({}, options, {unset: true}));
},

// Determine if the model has changed since the last `"change"` event.
// If you specify an attribute name, determine if that attribute has changed.
hasChanged: function(attr) {
if (attr == null) return !_.isEmpty(this.changed);
return _.has(this.changed, attr);
},

// Return an object containing all the attributes that have changed, or


// false if there are no changed attributes. Useful for determining what
// parts of a view need to be updated and/or what attributes need to be
// persisted to the server. Unset attributes will be set to undefined.
// You can also pass an attributes object to diff against the model,
// determining if there *would be* a change.
changedAttributes: function(diff) {
if (!diff) return this.hasChanged() ? _.clone(this.changed) : false;
var val, changed = false;
var old = this._changing ? this._previousAttributes : this.attributes;
for (var attr in diff) {
if (_.isEqual(old[attr], (val = diff[attr]))) continue;
(changed || (changed = {}))[attr] = val;
}
return changed;
},

// Get the previous value of an attribute, recorded at the time the last
// `"change"` event was fired.
previous: function(attr) {
if (attr == null || !this._previousAttributes) return null;
return this._previousAttributes[attr];
},

// Get all of the attributes of the model at the time of the previous
// `"change"` event.
previousAttributes: function() {
return _.clone(this._previousAttributes);
},

// Fetch the model from the server. If the server's representation of the
// model differs from its current attributes, they will be overridden,
// triggering a `"change"` event.
fetch: function(options) {
options = options ? _.clone(options) : {};
if (options.parse === void 0) options.parse = true;
var model = this;
var success = options.success;
options.success = function(resp) {
if (!model.set(model.parse(resp, options), options)) return false;
if (success) success(model, resp, options);
model.trigger('sync', model, resp, options);
};
wrapError(this, options);
return this.sync('read', this, options);
},

// Set a hash of model attributes, and sync the model to the server.
// If the server returns an attributes hash that differs, the model's
// state will be `set` again.
save: function(key, val, options) {
var attrs, method, xhr, attributes = this.attributes;

// Handle both `"key", value` and `{key: value}` -style arguments.


if (key == null || typeof key === 'object') {
attrs = key;
options = val;
} else {
(attrs = {})[key] = val;
}

options = _.extend({validate: true}, options);

// If we're not waiting and attributes exist, save acts as


// `set(attr).save(null, opts)` with validation. Otherwise, check if
// the model will be valid when the attributes, if any, are set.
if (attrs && !options.wait) {
if (!this.set(attrs, options)) return false;
} else {
if (!this._validate(attrs, options)) return false;
}

// Set temporary attributes if `{wait: true}`.


if (attrs && options.wait) {
this.attributes = _.extend({}, attributes, attrs);
}

// After a successful server-side save, the client is (optionally)


// updated with the server-side state.
if (options.parse === void 0) options.parse = true;
var model = this;
var success = options.success;
options.success = function(resp) {
// Ensure attributes are restored during synchronous saves.
model.attributes = attributes;
var serverAttrs = model.parse(resp, options);
if (options.wait) serverAttrs = _.extend(attrs || {}, serverAttrs);
if (_.isObject(serverAttrs) && !model.set(serverAttrs, options)) {
return false;
}
if (success) success(model, resp, options);
model.trigger('sync', model, resp, options);
};
wrapError(this, options);

method = this.isNew() ? 'create' : (options.patch ? 'patch' : 'update');


if (method === 'patch') options.attrs = attrs;
xhr = this.sync(method, this, options);

// Restore attributes.
if (attrs && options.wait) this.attributes = attributes;

return xhr;
},

// Destroy this model on the server if it was already persisted.


// Optimistically removes the model from its collection, if it has one.
// If `wait: true` is passed, waits for the server to respond before removal.
destroy: function(options) {
options = options ? _.clone(options) : {};
var model = this;
var success = options.success;

var destroy = function() {


model.trigger('destroy', model, model.collection, options);
};

options.success = function(resp) {
if (options.wait || model.isNew()) destroy();
if (success) success(model, resp, options);
if (!model.isNew()) model.trigger('sync', model, resp, options);
};

if (this.isNew()) {
options.success();
return false;
}
wrapError(this, options);

var xhr = this.sync('delete', this, options);


if (!options.wait) destroy();
return xhr;
},

// Default URL for the model's representation on the server -- if you're


// using Backbone's restful methods, override this to change the endpoint
// that will be called.
url: function() {
var base = _.result(this, 'urlRoot') || _.result(this.collection, 'url') ||
urlError();
if (this.isNew()) return base;
return base + (base.charAt(base.length - 1) === '/' ? '' : '/') +
encodeURIComponent(this.id);
},

// **parse** converts a response into the hash of attributes to be `set` on


// the model. The default implementation is just to pass the response along.
parse: function(resp, options) {
return resp;
},

// Create a new model with identical attributes to this one.


clone: function() {
return new this.constructor(this.attributes);
},

// A model is new if it has never been saved to the server, and lacks an id.
isNew: function() {
return this.id == null;
},

// Check if the model is currently in a valid state.


isValid: function(options) {
return this._validate({}, _.extend(options || {}, { validate: true }));
},
// Run validation against the next complete set of model attributes,
// returning `true` if all is well. Otherwise, fire an `"invalid"` event.
_validate: function(attrs, options) {
if (!options.validate || !this.validate) return true;
attrs = _.extend({}, this.attributes, attrs);
var error = this.validationError = this.validate(attrs, options) || null;
if (!error) return true;
this.trigger('invalid', this, error, _.extend(options, {validationError:
error}));
return false;
}

});

// Underscore methods that we want to implement on the Model.


var modelMethods = ['keys', 'values', 'pairs', 'invert', 'pick', 'omit'];

// Mix in each Underscore method as a proxy to `Model#attributes`.


_.each(modelMethods, function(method) {
Model.prototype[method] = function() {
var args = slice.call(arguments);
args.unshift(this.attributes);
return _[method].apply(_, args);
};
});

// Backbone.Collection
// -------------------

// If models tend to represent a single row of data, a Backbone Collection is


// more analagous to a table full of data ... or a small slice or page of that
// table, or a collection of rows that belong together for a particular reason
// -- all of the messages in this particular folder, all of the documents
// belonging to this particular author, and so on. Collections maintain
// indexes of their models, both in order, and for lookup by `id`.

// Create a new **Collection**, perhaps to contain a specific type of `model`.


// If a `comparator` is specified, the Collection will maintain
// its models in sort order, as they're added and removed.
var Collection = Backbone.Collection = function(models, options) {
options || (options = {});
if (options.model) this.model = options.model;
if (options.comparator !== void 0) this.comparator = options.comparator;
this._reset();
this.initialize.apply(this, arguments);
if (models) this.reset(models, _.extend({silent: true}, options));
};

// Default options for `Collection#set`.


var setOptions = {add: true, remove: true, merge: true};
var addOptions = {add: true, remove: false};

// Define the Collection's inheritable methods.


_.extend(Collection.prototype, Events, {

// The default model for a collection is just a **Backbone.Model**.


// This should be overridden in most cases.
model: Model,

// Initialize is an empty function by default. Override it with your own


// initialization logic.
initialize: function(){},
// The JSON representation of a Collection is an array of the
// models' attributes.
toJSON: function(options) {
return this.map(function(model){ return model.toJSON(options); });
},

// Proxy `Backbone.sync` by default.


sync: function() {
return Backbone.sync.apply(this, arguments);
},

// Add a model, or list of models to the set.


add: function(models, options) {
return this.set(models, _.extend({merge: false}, options, addOptions));
},

// Remove a model, or a list of models from the set.


remove: function(models, options) {
var singular = !_.isArray(models);
models = singular ? [models] : _.clone(models);
options || (options = {});
var i, l, index, model;
for (i = 0, l = models.length; i < l; i++) {
model = models[i] = this.get(models[i]);
if (!model) continue;
delete this._byId[model.id];
delete this._byId[model.cid];
index = this.indexOf(model);
this.models.splice(index, 1);
this.length--;
if (!options.silent) {
options.index = index;
model.trigger('remove', model, this, options);
}
this._removeReference(model);
}
return singular ? models[0] : models;
},

// Update a collection by `set`-ing a new list of models, adding new ones,


// removing models that are no longer present, and merging models that
// already exist in the collection, as necessary. Similar to **Model#set**,
// the core operation for updating the data contained by the collection.
set: function(models, options) {
options = _.defaults({}, options, setOptions);
if (options.parse) models = this.parse(models, options);
var singular = !_.isArray(models);
models = singular ? (models ? [models] : []) : _.clone(models);
var i, l, id, model, attrs, existing, sort;
var at = options.at;
var targetModel = this.model;
var sortable = this.comparator && (at == null) && options.sort !== false;
var sortAttr = _.isString(this.comparator) ? this.comparator : null;
var toAdd = [], toRemove = [], modelMap = {};
var add = options.add, merge = options.merge, remove = options.remove;
var order = !sortable && add && remove ? [] : false;

// Turn bare objects into model references, and prevent invalid models
// from being added.
for (i = 0, l = models.length; i < l; i++) {
attrs = models[i];
if (attrs instanceof Model) {
id = model = attrs;
} else {
id = attrs[targetModel.prototype.idAttribute];
}

// If a duplicate is found, prevent it from being added and


// optionally merge it into the existing model.
if (existing = this.get(id)) {
if (remove) modelMap[existing.cid] = true;
if (merge) {
attrs = attrs === model ? model.attributes : attrs;
if (options.parse) attrs = existing.parse(attrs, options);
existing.set(attrs, options);
if (sortable && !sort && existing.hasChanged(sortAttr)) sort = true;
}
models[i] = existing;

// If this is a new, valid model, push it to the `toAdd` list.


} else if (add) {
model = models[i] = this._prepareModel(attrs, options);
if (!model) continue;
toAdd.push(model);

// Listen to added models' events, and index models for lookup by


// `id` and by `cid`.
model.on('all', this._onModelEvent, this);
this._byId[model.cid] = model;
if (model.id != null) this._byId[model.id] = model;
}
if (order) order.push(existing || model);
}

// Remove nonexistent models if appropriate.


if (remove) {
for (i = 0, l = this.length; i < l; ++i) {
if (!modelMap[(model = this.models[i]).cid]) toRemove.push(model);
}
if (toRemove.length) this.remove(toRemove, options);
}

// See if sorting is needed, update `length` and splice in new models.


if (toAdd.length || (order && order.length)) {
if (sortable) sort = true;
this.length += toAdd.length;
if (at != null) {
for (i = 0, l = toAdd.length; i < l; i++) {
this.models.splice(at + i, 0, toAdd[i]);
}
} else {
if (order) this.models.length = 0;
var orderedModels = order || toAdd;
for (i = 0, l = orderedModels.length; i < l; i++) {
this.models.push(orderedModels[i]);
}
}
}

// Silently sort the collection if appropriate.


if (sort) this.sort({silent: true});

// Unless silenced, it's time to fire all appropriate add/sort events.


if (!options.silent) {
for (i = 0, l = toAdd.length; i < l; i++) {
(model = toAdd[i]).trigger('add', model, this, options);
}
if (sort || (order && order.length)) this.trigger('sort', this, options);
}

// Return the added (or merged) model (or models).


return singular ? models[0] : models;
},

// When you have more items than you want to add or remove individually,
// you can reset the entire set with a new list of models, without firing
// any granular `add` or `remove` events. Fires `reset` when finished.
// Useful for bulk operations and optimizations.
reset: function(models, options) {
options || (options = {});
for (var i = 0, l = this.models.length; i < l; i++) {
this._removeReference(this.models[i]);
}
options.previousModels = this.models;
this._reset();
models = this.add(models, _.extend({silent: true}, options));
if (!options.silent) this.trigger('reset', this, options);
return models;
},

// Add a model to the end of the collection.


push: function(model, options) {
return this.add(model, _.extend({at: this.length}, options));
},

// Remove a model from the end of the collection.


pop: function(options) {
var model = this.at(this.length - 1);
this.remove(model, options);
return model;
},

// Add a model to the beginning of the collection.


unshift: function(model, options) {
return this.add(model, _.extend({at: 0}, options));
},

// Remove a model from the beginning of the collection.


shift: function(options) {
var model = this.at(0);
this.remove(model, options);
return model;
},

// Slice out a sub-array of models from the collection.


slice: function() {
return slice.apply(this.models, arguments);
},

// Get a model from the set by id.


get: function(obj) {
if (obj == null) return void 0;
return this._byId[obj.id] || this._byId[obj.cid] || this._byId[obj];
},

// Get the model at the given index.


at: function(index) {
return this.models[index];
},
// Return models with matching attributes. Useful for simple cases of
// `filter`.
where: function(attrs, first) {
if (_.isEmpty(attrs)) return first ? void 0 : [];
return this[first ? 'find' : 'filter'](function(model) {
for (var key in attrs) {
if (attrs[key] !== model.get(key)) return false;
}
return true;
});
},

// Return the first model with matching attributes. Useful for simple cases
// of `find`.
findWhere: function(attrs) {
return this.where(attrs, true);
},

// Force the collection to re-sort itself. You don't need to call this under
// normal circumstances, as the set will maintain sort order as each item
// is added.
sort: function(options) {
if (!this.comparator) throw new Error('Cannot sort a set without a comparator');
options || (options = {});

// Run sort based on type of `comparator`.


if (_.isString(this.comparator) || this.comparator.length === 1) {
this.models = this.sortBy(this.comparator, this);
} else {
this.models.sort(_.bind(this.comparator, this));
}

if (!options.silent) this.trigger('sort', this, options);


return this;
},

// Pluck an attribute from each model in the collection.


pluck: function(attr) {
return _.invoke(this.models, 'get', attr);
},

// Fetch the default set of models for this collection, resetting the
// collection when they arrive. If `reset: true` is passed, the response
// data will be passed through the `reset` method instead of `set`.
fetch: function(options) {
options = options ? _.clone(options) : {};
if (options.parse === void 0) options.parse = true;
var success = options.success;
var collection = this;
options.success = function(resp) {
var method = options.reset ? 'reset' : 'set';
collection[method](resp, options);
if (success) success(collection, resp, options);
collection.trigger('sync', collection, resp, options);
};
wrapError(this, options);
return this.sync('read', this, options);
},

// Create a new instance of a model in this collection. Add the model to the
// collection immediately, unless `wait: true` is passed, in which case we
// wait for the server to agree.
create: function(model, options) {
options = options ? _.clone(options) : {};
if (!(model = this._prepareModel(model, options))) return false;
if (!options.wait) this.add(model, options);
var collection = this;
var success = options.success;
options.success = function(model, resp, options) {
if (options.wait) collection.add(model, options);
if (success) success(model, resp, options);
};
model.save(null, options);
return model;
},

// **parse** converts a response into a list of models to be added to the


// collection. The default implementation is just to pass it through.
parse: function(resp, options) {
return resp;
},

// Create a new collection with an identical list of models as this one.


clone: function() {
return new this.constructor(this.models);
},

// Private method to reset all internal state. Called when the collection
// is first initialized or reset.
_reset: function() {
this.length = 0;
this.models = [];
this._byId = {};
},

// Prepare a hash of attributes (or other model) to be added to this


// collection.
_prepareModel: function(attrs, options) {
if (attrs instanceof Model) {
if (!attrs.collection) attrs.collection = this;
return attrs;
}
options = options ? _.clone(options) : {};
options.collection = this;
var model = new this.model(attrs, options);
if (!model.validationError) return model;
this.trigger('invalid', this, model.validationError, options);
return false;
},

// Internal method to sever a model's ties to a collection.


_removeReference: function(model) {
if (this === model.collection) delete model.collection;
model.off('all', this._onModelEvent, this);
},

// Internal method called every time a model in the set fires an event.
// Sets need to update their indexes when models change ids. All other
// events simply proxy through. "add" and "remove" events that originate
// in other collections are ignored.
_onModelEvent: function(event, model, collection, options) {
if ((event === 'add' || event === 'remove') && collection !== this) return;
if (event === 'destroy') this.remove(model, options);
if (model && event === 'change:' + model.idAttribute) {
delete this._byId[model.previous(model.idAttribute)];
if (model.id != null) this._byId[model.id] = model;
}
this.trigger.apply(this, arguments);
}

});

// Underscore methods that we want to implement on the Collection.


// 90% of the core usefulness of Backbone Collections is actually implemented
// right here:
var methods = ['forEach', 'each', 'map', 'collect', 'reduce', 'foldl',
'inject', 'reduceRight', 'foldr', 'find', 'detect', 'filter', 'select',
'reject', 'every', 'all', 'some', 'any', 'include', 'contains', 'invoke',
'max', 'min', 'toArray', 'size', 'first', 'head', 'take', 'initial', 'rest',
'tail', 'drop', 'last', 'without', 'difference', 'indexOf', 'shuffle',
'lastIndexOf', 'isEmpty', 'chain'];

// Mix in each Underscore method as a proxy to `Collection#models`.


_.each(methods, function(method) {
Collection.prototype[method] = function() {
var args = slice.call(arguments);
args.unshift(this.models);
return _[method].apply(_, args);
};
});

// Underscore methods that take a property name as an argument.


var attributeMethods = ['groupBy', 'countBy', 'sortBy'];

// Use attributes instead of properties.


_.each(attributeMethods, function(method) {
Collection.prototype[method] = function(value, context) {
var iterator = _.isFunction(value) ? value : function(model) {
return model.get(value);
};
return _[method](this.models, iterator, context);
};
});

// Backbone.View
// -------------

// Backbone Views are almost more convention than they are actual code. A View
// is simply a JavaScript object that represents a logical chunk of UI in the
// DOM. This might be a single item, an entire list, a sidebar or panel, or
// even the surrounding frame which wraps your whole app. Defining a chunk of
// UI as a **View** allows you to define your DOM events declaratively, without
// having to worry about render order ... and makes it easy for the view to
// react to specific changes in the state of your models.

// Creating a Backbone.View creates its initial element outside of the DOM,


// if an existing element is not provided...
var View = Backbone.View = function(options) {
this.cid = _.uniqueId('view');
options || (options = {});
_.extend(this, _.pick(options, viewOptions));
this._ensureElement();
this.initialize.apply(this, arguments);
this.delegateEvents();
};

// Cached regex to split keys for `delegate`.


var delegateEventSplitter = /^(\S+)\s*(.*)$/;
// List of view options to be merged as properties.
var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className',
'tagName', 'events'];

// Set up all inheritable **Backbone.View** properties and methods.


_.extend(View.prototype, Events, {

// The default `tagName` of a View's element is `"div"`.


tagName: 'div',

// jQuery delegate for element lookup, scoped to DOM elements within the
// current view. This should be preferred to global lookups where possible.
$: function(selector) {
return this.$el.find(selector);
},

// Initialize is an empty function by default. Override it with your own


// initialization logic.
initialize: function(){},

// **render** is the core function that your view should override, in order
// to populate its element (`this.el`), with the appropriate HTML. The
// convention is for **render** to always return `this`.
render: function() {
return this;
},

// Remove this view by taking the element out of the DOM, and removing any
// applicable Backbone.Events listeners.
remove: function() {
this.$el.remove();
this.stopListening();
return this;
},

// Change the view's element (`this.el` property), including event


// re-delegation.
setElement: function(element, delegate) {
if (this.$el) this.undelegateEvents();
this.$el = element instanceof Backbone.$ ? element : Backbone.$(element);
this.el = this.$el[0];
if (delegate !== false) this.delegateEvents();
return this;
},

// Set callbacks, where `this.events` is a hash of


//
// *{"event selector": "callback"}*
//
// {
// 'mousedown .title': 'edit',
// 'click .button': 'save',
// 'click .open': function(e) { ... }
// }
//
// pairs. Callbacks will be bound to the view, with `this` set properly.
// Uses event delegation for efficiency.
// Omitting the selector binds the event to `this.el`.
// This only works for delegate-able events: not `focus`, `blur`, and
// not `change`, `submit`, and `reset` in Internet Explorer.
delegateEvents: function(events) {
if (!(events || (events = _.result(this, 'events')))) return this;
this.undelegateEvents();
for (var key in events) {
var method = events[key];
if (!_.isFunction(method)) method = this[events[key]];
if (!method) continue;

var match = key.match(delegateEventSplitter);


var eventName = match[1], selector = match[2];
method = _.bind(method, this);
eventName += '.delegateEvents' + this.cid;
if (selector === '') {
this.$el.on(eventName, method);
} else {
this.$el.on(eventName, selector, method);
}
}
return this;
},

// Clears all callbacks previously bound to the view with `delegateEvents`.


// You usually don't need to use this, but may wish to if you have multiple
// Backbone views attached to the same DOM element.
undelegateEvents: function() {
this.$el.off('.delegateEvents' + this.cid);
return this;
},

// Ensure that the View has a DOM element to render into.


// If `this.el` is a string, pass it through `$()`, take the first
// matching element, and re-assign it to `el`. Otherwise, create
// an element from the `id`, `className` and `tagName` properties.
_ensureElement: function() {
if (!this.el) {
var attrs = _.extend({}, _.result(this, 'attributes'));
if (this.id) attrs.id = _.result(this, 'id');
if (this.className) attrs['class'] = _.result(this, 'className');
var $el = Backbone.$('<' + _.result(this, 'tagName') + '>').attr(attrs);
this.setElement($el, false);
} else {
this.setElement(_.result(this, 'el'), false);
}
}

});

// Backbone.sync
// -------------

// Override this function to change the manner in which Backbone persists


// models to the server. You will be passed the type of request, and the
// model in question. By default, makes a RESTful Ajax request
// to the model's `url()`. Some possible customizations could be:
//
// * Use `setTimeout` to batch rapid-fire updates into a single request.
// * Send up the models as XML instead of JSON.
// * Persist models via WebSockets instead of Ajax.
//
// Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests
// as `POST`, with a `_method` parameter containing the true HTTP method,
// as well as all requests with the body as `application/x-www-form-urlencoded`
// instead of `application/json` with the model in a param named `model`.
// Useful when interfacing with server-side languages like **PHP** that make
// it difficult to read the body of `PUT` requests.
Backbone.sync = function(method, model, options) {
var type = methodMap[method];

// Default options, unless specified.


_.defaults(options || (options = {}), {
emulateHTTP: Backbone.emulateHTTP,
emulateJSON: Backbone.emulateJSON
});

// Default JSON-request options.


var params = {type: type, dataType: 'json'};

// Ensure that we have a URL.


if (!options.url) {
params.url = _.result(model, 'url') || urlError();
}

// Ensure that we have the appropriate request data.


if (options.data == null && model && (method === 'create' || method === 'update'
|| method === 'patch')) {
params.contentType = 'application/json';
params.data = JSON.stringify(options.attrs || model.toJSON(options));
}

// For older servers, emulate JSON by encoding the request into an HTML-form.
if (options.emulateJSON) {
params.contentType = 'application/x-www-form-urlencoded';
params.data = params.data ? {model: params.data} : {};
}

// For older servers, emulate HTTP by mimicking the HTTP method with `_method`
// And an `X-HTTP-Method-Override` header.
if (options.emulateHTTP && (type === 'PUT' || type === 'DELETE' || type ===
'PATCH')) {
params.type = 'POST';
if (options.emulateJSON) params.data._method = type;
var beforeSend = options.beforeSend;
options.beforeSend = function(xhr) {
xhr.setRequestHeader('X-HTTP-Method-Override', type);
if (beforeSend) return beforeSend.apply(this, arguments);
};
}

// Don't process data on a non-GET request.


if (params.type !== 'GET' && !options.emulateJSON) {
params.processData = false;
}

// If we're sending a `PATCH` request, and we're in an old Internet Explorer


// that still has ActiveX enabled by default, override jQuery to use that
// for XHR instead. Remove this line when jQuery supports `PATCH` on IE8.
if (params.type === 'PATCH' && noXhrPatch) {
params.xhr = function() {
return new ActiveXObject("Microsoft.XMLHTTP");
};
}

// Make the request, allowing the user to override any Ajax options.
var xhr = options.xhr = Backbone.ajax(_.extend(params, options));
model.trigger('request', model, xhr, options);
return xhr;
};
var noXhrPatch = typeof window !== 'undefined' && !!window.ActiveXObject &&
!(window.XMLHttpRequest && (new XMLHttpRequest).dispatchEvent);

// Map from CRUD to HTTP for our default `Backbone.sync` implementation.


var methodMap = {
'create': 'POST',
'update': 'PUT',
'patch': 'PATCH',
'delete': 'DELETE',
'read': 'GET'
};

// Set the default implementation of `Backbone.ajax` to proxy through to `$`.


// Override this if you'd like to use a different library.
Backbone.ajax = function() {
return Backbone.$.ajax.apply(Backbone.$, arguments);
};

// Backbone.Router
// ---------------

// Routers map faux-URLs to actions, and fire events when routes are
// matched. Creating a new one sets its `routes` hash, if not set statically.
var Router = Backbone.Router = function(options) {
options || (options = {});
if (options.routes) this.routes = options.routes;
this._bindRoutes();
this.initialize.apply(this, arguments);
};

// Cached regular expressions for matching named param parts and splatted
// parts of route strings.
var optionalParam = /\((.*?)\)/g;
var namedParam = /(\(\?)?:\w+/g;
var splatParam = /\*\w+/g;
var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g;

// Set up all inheritable **Backbone.Router** properties and methods.


_.extend(Router.prototype, Events, {

// Initialize is an empty function by default. Override it with your own


// initialization logic.
initialize: function(){},

// Manually bind a single named route to a callback. For example:


//
// this.route('search/:query/p:num', 'search', function(query, num) {
// ...
// });
//
route: function(route, name, callback) {
if (!_.isRegExp(route)) route = this._routeToRegExp(route);
if (_.isFunction(name)) {
callback = name;
name = '';
}
if (!callback) callback = this[name];
var router = this;
Backbone.history.route(route, function(fragment) {
var args = router._extractParameters(route, fragment);
callback && callback.apply(router, args);
router.trigger.apply(router, ['route:' + name].concat(args));
router.trigger('route', name, args);
Backbone.history.trigger('route', router, name, args);
});
return this;
},

// Simple proxy to `Backbone.history` to save a fragment into the history.


navigate: function(fragment, options) {
Backbone.history.navigate(fragment, options);
return this;
},

// Bind all defined routes to `Backbone.history`. We have to reverse the


// order of the routes here to support behavior where the most general
// routes can be defined at the bottom of the route map.
_bindRoutes: function() {
if (!this.routes) return;
this.routes = _.result(this, 'routes');
var route, routes = _.keys(this.routes);
while ((route = routes.pop()) != null) {
this.route(route, this.routes[route]);
}
},

// Convert a route string into a regular expression, suitable for matching


// against the current location hash.
_routeToRegExp: function(route) {
route = route.replace(escapeRegExp, '\\$&')
.replace(optionalParam, '(?:$1)?')
.replace(namedParam, function(match, optional) {
return optional ? match : '([^\/]+)';
})
.replace(splatParam, '(.*?)');
return new RegExp('^' + route + '$');
},

// Given a route, and a URL fragment that it matches, return the array of
// extracted decoded parameters. Empty or unmatched parameters will be
// treated as `null` to normalize cross-browser behavior.
_extractParameters: function(route, fragment) {
var params = route.exec(fragment).slice(1);
return _.map(params, function(param) {
return param ? decodeURIComponent(param) : null;
});
}

});

// Backbone.History
// ----------------

// Handles cross-browser history management, based on either


// [pushState](http://diveintohtml5.info/history.html) and real URLs, or
// [onhashchange](https://developer.mozilla.org/en-US/docs/DOM/window.onhashchange)
// and URL fragments. If the browser supports neither (old IE, natch),
// falls back to polling.
var History = Backbone.History = function() {
this.handlers = [];
_.bindAll(this, 'checkUrl');

// Ensure that `History` can be used outside of the browser.


if (typeof window !== 'undefined') {
this.location = window.location;
this.history = window.history;
}
};

// Cached regex for stripping a leading hash/slash and trailing space.


var routeStripper = /^[#\/]|\s+$/g;

// Cached regex for stripping leading and trailing slashes.


var rootStripper = /^\/+|\/+$/g;

// Cached regex for detecting MSIE.


var isExplorer = /msie [\w.]+/;

// Cached regex for removing a trailing slash.


var trailingSlash = /\/$/;

// Cached regex for stripping urls of hash and query.


var pathStripper = /[?#].*$/;

// Has the history handling already been started?


History.started = false;

// Set up all inheritable **Backbone.History** properties and methods.


_.extend(History.prototype, Events, {

// The default interval to poll for hash changes, if necessary, is


// twenty times a second.
interval: 50,

// Gets the true hash value. Cannot use location.hash directly due to bug
// in Firefox where location.hash will always be decoded.
getHash: function(window) {
var match = (window || this).location.href.match(/#(.*)$/);
return match ? match[1] : '';
},

// Get the cross-browser normalized URL fragment, either from the URL,
// the hash, or the override.
getFragment: function(fragment, forcePushState) {
if (fragment == null) {
if (this._hasPushState || !this._wantsHashChange || forcePushState) {
fragment = this.location.pathname;
var root = this.root.replace(trailingSlash, '');
if (!fragment.indexOf(root)) fragment = fragment.slice(root.length);
} else {
fragment = this.getHash();
}
}
return fragment.replace(routeStripper, '');
},

// Start the hash change handling, returning `true` if the current URL matches
// an existing route, and `false` otherwise.
start: function(options) {
if (History.started) throw new Error("Backbone.history has already been
started");
History.started = true;

// Figure out the initial configuration. Do we need an iframe?


// Is pushState desired ... is it available?
this.options = _.extend({root: '/'}, this.options, options);
this.root = this.options.root;
this._wantsHashChange = this.options.hashChange !== false;
this._wantsPushState = !!this.options.pushState;
this._hasPushState = !!(this.options.pushState && this.history &&
this.history.pushState);
var fragment = this.getFragment();
var docMode = document.documentMode;
var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) &&
(!docMode || docMode <= 7));

// Normalize root to always include a leading and trailing slash.


this.root = ('/' + this.root + '/').replace(rootStripper, '/');

if (oldIE && this._wantsHashChange) {


this.iframe = Backbone.$('<iframe src="javascript:0" tabindex="-1"
/>').hide().appendTo('body')[0].contentWindow;
this.navigate(fragment);
}

// Depending on whether we're using pushState or hashes, and whether


// 'onhashchange' is supported, determine how we check the URL state.
if (this._hasPushState) {
Backbone.$(window).on('popstate', this.checkUrl);
} else if (this._wantsHashChange && ('onhashchange' in window) && !oldIE) {
Backbone.$(window).on('hashchange', this.checkUrl);
} else if (this._wantsHashChange) {
this._checkUrlInterval = setInterval(this.checkUrl, this.interval);
}

// Determine if we need to change the base url, for a pushState link


// opened by a non-pushState browser.
this.fragment = fragment;
var loc = this.location;
var atRoot = loc.pathname.replace(/[^\/]$/, '$&/') === this.root;

// Transition from hashChange to pushState or vice versa if both are


// requested.
if (this._wantsHashChange && this._wantsPushState) {

// If we've started off with a route from a `pushState`-enabled


// browser, but we're currently in a browser that doesn't support it...
if (!this._hasPushState && !atRoot) {
this.fragment = this.getFragment(null, true);
this.location.replace(this.root + this.location.search + '#' +
this.fragment);
// Return immediately as browser will do redirect to new url
return true;

// Or if we've started out with a hash-based route, but we're currently


// in a browser where it could be `pushState`-based instead...
} else if (this._hasPushState && atRoot && loc.hash) {
this.fragment = this.getHash().replace(routeStripper, '');
this.history.replaceState({}, document.title, this.root + this.fragment +
loc.search);
}

if (!this.options.silent) return this.loadUrl();


},

// Disable Backbone.history, perhaps temporarily. Not useful in a real app,


// but possibly useful for unit testing Routers.
stop: function() {
Backbone.$(window).off('popstate', this.checkUrl).off('hashchange',
this.checkUrl);
clearInterval(this._checkUrlInterval);
History.started = false;
},

// Add a route to be tested when the fragment changes. Routes added later
// may override previous routes.
route: function(route, callback) {
this.handlers.unshift({route: route, callback: callback});
},

// Checks the current URL to see if it has changed, and if it has,


// calls `loadUrl`, normalizing across the hidden iframe.
checkUrl: function(e) {
var current = this.getFragment();
if (current === this.fragment && this.iframe) {
current = this.getFragment(this.getHash(this.iframe));
}
if (current === this.fragment) return false;
if (this.iframe) this.navigate(current);
this.loadUrl();
},

// Attempt to load the current URL fragment. If a route succeeds with a


// match, returns `true`. If no defined routes matches the fragment,
// returns `false`.
loadUrl: function(fragment) {
fragment = this.fragment = this.getFragment(fragment);
return _.any(this.handlers, function(handler) {
if (handler.route.test(fragment)) {
handler.callback(fragment);
return true;
}
});
},

// Save a fragment into the hash history, or replace the URL state if the
// 'replace' option is passed. You are responsible for properly URL-encoding
// the fragment in advance.
//
// The options object can contain `trigger: true` if you wish to have the
// route callback be fired (not usually desirable), or `replace: true`, if
// you wish to modify the current URL without adding an entry to the history.
navigate: function(fragment, options) {
if (!History.started) return false;
if (!options || options === true) options = {trigger: !!options};

var url = this.root + (fragment = this.getFragment(fragment || ''));

// Strip the fragment of the query and hash for matching.


fragment = fragment.replace(pathStripper, '');

if (this.fragment === fragment) return;


this.fragment = fragment;

// Don't include a trailing slash on the root.


if (fragment === '' && url !== '/') url = url.slice(0, -1);

// If pushState is available, we use it to set the fragment as a real URL.


if (this._hasPushState) {
this.history[options.replace ? 'replaceState' : 'pushState']({},
document.title, url);

// If hash changes haven't been explicitly disabled, update the hash


// fragment to store history.
} else if (this._wantsHashChange) {
this._updateHash(this.location, fragment, options.replace);
if (this.iframe && (fragment !== this.getFragment(this.getHash(this.iframe))))
{
// Opening and closing the iframe tricks IE7 and earlier to push a
// history entry on hash-tag change. When replace is true, we don't
// want this.
if(!options.replace) this.iframe.document.open().close();
this._updateHash(this.iframe.location, fragment, options.replace);
}

// If you've told us that you explicitly don't want fallback hashchange-


// based history, then `navigate` becomes a page refresh.
} else {
return this.location.assign(url);
}
if (options.trigger) return this.loadUrl(fragment);
},

// Update the hash location, either replacing the current entry, or adding
// a new one to the browser history.
_updateHash: function(location, fragment, replace) {
if (replace) {
var href = location.href.replace(/(javascript:|#).*$/, '');
location.replace(href + '#' + fragment);
} else {
// Some browsers require that `hash` contains a leading #.
location.hash = '#' + fragment;
}
}

});

// Create the default Backbone.history.


Backbone.history = new History;

// Helpers
// -------

// Helper function to correctly set up the prototype chain, for subclasses.


// Similar to `goog.inherits`, but uses a hash of prototype properties and
// class properties to be extended.
var extend = function(protoProps, staticProps) {
var parent = this;
var child;

// The constructor function for the new subclass is either defined by you
// (the "constructor" property in your `extend` definition), or defaulted
// by us to simply call the parent's constructor.
if (protoProps && _.has(protoProps, 'constructor')) {
child = protoProps.constructor;
} else {
child = function(){ return parent.apply(this, arguments); };
}

// Add static properties to the constructor function, if supplied.


_.extend(child, parent, staticProps);

// Set the prototype chain to inherit from `parent`, without calling


// `parent`'s constructor function.
var Surrogate = function(){ this.constructor = child; };
Surrogate.prototype = parent.prototype;
child.prototype = new Surrogate;

// Add prototype properties (instance properties) to the subclass,


// if supplied.
if (protoProps) _.extend(child.prototype, protoProps);

// Set a convenience property in case the parent's prototype is needed


// later.
child.__super__ = parent.prototype;

return child;
};

// Set up inheritance for the model, collection, router, view and history.
Model.extend = Collection.extend = Router.extend = View.extend = History.extend =
extend;

// Throw an error when a URL is needed, and none is supplied.


var urlError = function() {
throw new Error('A "url" property or function must be specified');
};

// Wrap an optional error callback with a fallback error event.


var wrapError = function(model, options) {
var error = options.error;
options.error = function(resp) {
if (error) error(model, resp, options);
model.trigger('error', model, resp, options);
};
};

}).call(this);
POS Models JS
odoo.define('point_of_sale.models', function (require) {
"use strict";

var BarcodeParser = require('barcodes.BarcodeParser');


var PosDB = require('point_of_sale.DB');
var devices = require('point_of_sale.devices');
var core = require('web.core');
var Model = require('web.DataModel');
var formats = require('web.formats');
var session = require('web.session');
var time = require('web.time');
var utils = require('web.utils');

var QWeb = core.qweb;


var _t = core._t;
var Mutex = utils.Mutex;
var round_di = utils.round_decimals;
var round_pr = utils.round_precision;
var Backbone = window.Backbone;

var exports = {};

// The PosModel contains the Point Of Sale's representation of the backend.


// Since the PoS must work in standalone ( Without connection to the server )
// it must contains a representation of the server's PoS backend.
// (taxes, product list, configuration options, etc.) this representation
// is fetched and stored by the PosModel at the initialisation.
// this is done asynchronously, a ready deferred alows the GUI to wait interactively
// for the loading to be completed
// There is a single instance of the PosModel for each Front-End instance, it is
usually called
// 'pos' and is available to all widgets extending PosWidget.

exports.PosModel = Backbone.Model.extend({
initialize: function(session, attributes) {
Backbone.Model.prototype.initialize.call(this, attributes);
var self = this;
this.flush_mutex = new Mutex(); // used to make sure the
orders are sent to the server once at time
this.chrome = attributes.chrome;
this.gui = attributes.gui;

this.proxy = new devices.ProxyDevice(this); // used to


communicate to the hardware devices via a local proxy
this.barcode_reader = new devices.BarcodeReader({'pos': this,
proxy:this.proxy});

this.proxy_queue = new devices.JobQueue(); // used to prevent


parallels communications to the proxy
this.db = new PosDB(); // a local database used to
search trough products and categories & store pending orders
this.debug = core.debug; //debug mode

// Business data; loaded from the server at launch


this.company_logo = null;
this.company_logo_base64 = '';
this.currency = null;
this.shop = null;
this.company = null;
this.user = null;
this.users = [];
this.partners = [];
this.cashier = null;
this.cashregisters = [];
this.taxes = [];
this.pos_session = null;
this.config = null;
this.units = [];
this.units_by_id = {};
this.pricelist = null;
this.order_sequence = 1;
window.posmodel = this;

// these dynamic attributes can be watched for change by other models or


widgets
this.set({
'synch': { state:'connected', pending:0 },
'orders': new OrderCollection(),
'selectedOrder': null,
'selectedClient': null,
});

this.get('orders').bind('remove', function(order,_unused_,options){
self.on_removed_order(order,options.index,options.reason);
});

// Forward the 'client' attribute on the selected order to 'selectedClient'


function update_client() {
var order = self.get_order();
this.set('selectedClient', order ? order.get_client() : null );
}
this.get('orders').bind('add remove change', update_client, this);
this.bind('change:selectedOrder', update_client, this);

// We fetch the backend data on the server asynchronously. this is done only
when the pos user interface is launched,
// Any change on this data made on the server is thus not reflected on the
point of sale until it is relaunched.
// when all the data has loaded, we compute some stuff, and declare the Pos
ready to be used.
this.ready = this.load_server_data().then(function(){
return self.after_load_server_data();
});
},
after_load_server_data: function(){
this.load_orders();
this.set_start_order();
if(this.config.use_proxy){
return this.connect_to_proxy();
}
},
// releases ressources holds by the model at the end of life of the posmodel
destroy: function(){
// FIXME, should wait for flushing, return a deferred to indicate successfull
destruction
// this.flush();
this.proxy.close();
this.barcode_reader.disconnect();
this.barcode_reader.disconnect_from_proxy();
},

connect_to_proxy: function(){
var self = this;
var done = new $.Deferred();
this.barcode_reader.disconnect_from_proxy();
this.chrome.loading_message(_t('Connecting to the PosBox'),0);
this.chrome.loading_skip(function(){
self.proxy.stop_searching();
});
this.proxy.autoconnect({
force_ip: self.config.proxy_ip || undefined,
progress: function(prog){
self.chrome.loading_progress(prog);
},
}).then(function(){
if(self.config.iface_scan_via_proxy){
self.barcode_reader.connect_to_proxy();
}
}).always(function(){
done.resolve();
});
return done;
},

// Server side model loaders. This is the list of the models that need to be
loaded from
// the server. The models are loaded one by one by this list's order. The 'loaded'
callback
// is used to store the data in the appropriate place once it has been loaded.
This callback
// can return a deferred that will pause the loading of the next module.
// a shared temporary dictionary is available for loaders to communicate private
variables
// used during loading such as object ids, etc.
models: [
{
label: 'version',
loaded: function(self){
return
session.rpc('/web/webclient/version_info',{}).done(function(version) {
self.version = version;
});
},

},{
model: 'res.users',
fields: ['name','company_id'],
ids: function(self){ return [session.uid]; },
loaded: function(self,users){ self.user = users[0]; },
},{
model: 'res.company',
fields: [ 'currency_id', 'email', 'website', 'company_registry', 'vat',
'name', 'phone', 'partner_id' , 'country_id', 'tax_calculation_rounding_method'],
ids: function(self){ return [self.user.company_id[0]]; },
loaded: function(self,companies){ self.company = companies[0]; },
},{
model: 'decimal.precision',
fields: ['name','digits'],
loaded: function(self,dps){
self.dp = {};
for (var i = 0; i < dps.length; i++) {
self.dp[dps[i].name] = dps[i].digits;
}
},
},{
model: 'product.uom',
fields: [],
domain: null,
context: function(self){ return { active_test: false }; },
loaded: function(self,units){
self.units = units;
var units_by_id = {};
for(var i = 0, len = units.length; i < len; i++){
units_by_id[units[i].id] = units[i];
units[i].groupable = ( units[i].category_id[0] === 1 );
units[i].is_unit = ( units[i].id === 1 );
}
self.units_by_id = units_by_id;
}
},{
model: 'res.partner',
fields:
['name','street','city','state_id','country_id','vat','phone','zip','mobile','email','
barcode','write_date','property_account_position_id'],
domain: [['customer','=',true]],
loaded: function(self,partners){
self.partners = partners;
self.db.add_partners(partners);
},
},{
model: 'res.country',
fields: ['name'],
loaded: function(self,countries){
self.countries = countries;
self.company.country = null;
for (var i = 0; i < countries.length; i++) {
if (countries[i].id === self.company.country_id[0]){
self.company.country = countries[i];
}
}
},
},{
model: 'account.tax',
fields: ['name','amount', 'price_include', 'include_base_amount',
'amount_type', 'children_tax_ids'],
domain: null,
loaded: function(self, taxes){
self.taxes = taxes;
self.taxes_by_id = {};
_.each(taxes, function(tax){
self.taxes_by_id[tax.id] = tax;
});
_.each(self.taxes_by_id, function(tax) {
tax.children_tax_ids = _.map(tax.children_tax_ids, function
(child_tax_id) {
return self.taxes_by_id[child_tax_id];
});
});
},
},{
model: 'pos.session',
fields: ['id',
'journal_ids','name','user_id','config_id','start_at','stop_at','sequence_number','log
in_number'],
domain: function(self){ return
[['state','=','opened'],['user_id','=',session.uid]]; },
loaded: function(self,pos_sessions){
self.pos_session = pos_sessions[0];
},
},{
model: 'pos.config',
fields: [],
domain: function(self){ return [['id','=', self.pos_session.config_id[0]]]; },
loaded: function(self,configs){
self.config = configs[0];
self.config.use_proxy = self.config.iface_payment_terminal ||
self.config.iface_electronic_scale ||
self.config.iface_print_via_proxy ||
self.config.iface_scan_via_proxy ||
self.config.iface_cashdrawer;

if (self.config.company_id[0] !== self.user.company_id[0]) {


throw new Error(_t("Error: The Point of Sale User must belong to the
same company as the Point of Sale. You are probably trying to load the point of sale
as an administrator in a multi-company setup, with the administrator account set to
the wrong company."));
}

self.db.set_uuid(self.config.uuid);

var orders = self.db.get_orders();


for (var i = 0; i < orders.length; i++) {
self.pos_session.sequence_number =
Math.max(self.pos_session.sequence_number, orders[i].data.sequence_number+1);
}
},
},{
model: 'res.users',
fields: ['name','pos_security_pin','groups_id','barcode'],
domain: function(self){ return
[['company_id','=',self.user.company_id[0]],'|', ['groups_id','=',
self.config.group_pos_manager_id[0]],['groups_id','=',
self.config.group_pos_user_id[0]]]; },
loaded: function(self,users){
// we attribute a role to the user, 'cashier' or 'manager', depending
// on the group the user belongs.
var pos_users = [];
for (var i = 0; i < users.length; i++) {
var user = users[i];
for (var j = 0; j < user.groups_id.length; j++) {
var group_id = user.groups_id[j];
if (group_id === self.config.group_pos_manager_id[0]) {
user.role = 'manager';
break;
} else if (group_id === self.config.group_pos_user_id[0]) {
user.role = 'cashier';
}
}
if (user.role) {
pos_users.push(user);
}
// replace the current user with its updated version
if (user.id === self.user.id) {
self.user = user;
}
}
self.users = pos_users;
},
},{
model: 'stock.location',
fields: [],
ids: function(self){ return [self.config.stock_location_id[0]]; },
loaded: function(self, locations){ self.shop = locations[0]; },
},{
model: 'product.pricelist',
fields: ['currency_id'],
ids: function(self){ return [self.config.pricelist_id[0]]; },
loaded: function(self, pricelists){ self.pricelist = pricelists[0]; },
},{
model: 'res.currency',
fields: ['name','symbol','position','rounding'],
ids: function(self){ return [self.pricelist.currency_id[0]]; },
loaded: function(self, currencies){
self.currency = currencies[0];
if (self.currency.rounding > 0) {
self.currency.decimals = Math.ceil(Math.log(1.0 /
self.currency.rounding) / Math.log(10));
} else {
self.currency.decimals = 0;
}

},
},{
model: 'pos.category',
fields: ['id','name','parent_id','child_id','image'],
domain: null,
loaded: function(self, categories){
self.db.add_categories(categories);
},
},{
model: 'product.product',
fields: ['display_name', 'list_price','price','pos_categ_id', 'taxes_id',
'barcode', 'default_code',
'to_weight', 'uom_id', 'description_sale', 'description',
'product_tmpl_id','tracking'],
order: ['sequence','default_code','name'],
domain: [['sale_ok','=',true],['available_in_pos','=',true]],
context: function(self){ return { pricelist: self.pricelist.id,
display_default_code: false }; },
loaded: function(self, products){
self.db.add_products(products);
},
},{
model: 'account.bank.statement',
fields:
['account_id','currency_id','journal_id','state','name','user_id','pos_session_id'],
domain: function(self){ return [['state', '=', 'open'],['pos_session_id', '=',
self.pos_session.id]]; },
loaded: function(self, cashregisters, tmp){
self.cashregisters = cashregisters;

tmp.journals = [];
_.each(cashregisters,function(statement){
tmp.journals.push(statement.journal_id[0]);
});
},
},{
model: 'account.journal',
fields: ['type', 'sequence'],
domain: function(self,tmp){ return [['id','in',tmp.journals]]; },
loaded: function(self, journals){
var i;
self.journals = journals;

// associate the bank statements with their journals.


var cashregisters = self.cashregisters;
var ilen = cashregisters.length;
for(i = 0; i < ilen; i++){
for(var j = 0, jlen = journals.length; j < jlen; j++){
if(cashregisters[i].journal_id[0] === journals[j].id){
cashregisters[i].journal = journals[j];
}
}
}

self.cashregisters_by_id = {};
for (i = 0; i < self.cashregisters.length; i++) {
self.cashregisters_by_id[self.cashregisters[i].id] =
self.cashregisters[i];
}

self.cashregisters = self.cashregisters.sort(function(a,b){
// prefer cashregisters to be first in the list
if (a.journal.type == "cash" && b.journal.type != "cash") {
return -1;
} else if (a.journal.type != "cash" && b.journal.type == "cash") {
return 1;
} else {
return a.journal.sequence - b.journal.sequence;
}
});

},
}, {
model: 'account.fiscal.position',
fields: [],
domain: function(self){ return [['id','in',self.config.fiscal_position_ids]];
},
loaded: function(self, fiscal_positions){
self.fiscal_positions = fiscal_positions;
}
}, {
model: 'account.fiscal.position.tax',
fields: [],
domain: function(self){
var fiscal_position_tax_ids = [];

self.fiscal_positions.forEach(function (fiscal_position) {
fiscal_position.tax_ids.forEach(function (tax_id) {
fiscal_position_tax_ids.push(tax_id);
});
});

return [['id','in',fiscal_position_tax_ids]];
},
loaded: function(self, fiscal_position_taxes){
self.fiscal_position_taxes = fiscal_position_taxes;
self.fiscal_positions.forEach(function (fiscal_position) {
fiscal_position.fiscal_position_taxes_by_id = {};
fiscal_position.tax_ids.forEach(function (tax_id) {
var fiscal_position_tax = _.find(fiscal_position_taxes, function
(fiscal_position_tax) {
return fiscal_position_tax.id === tax_id;
});

fiscal_position.fiscal_position_taxes_by_id[fiscal_position_tax.id] =
fiscal_position_tax;
});
});
}
}, {
label: 'fonts',
loaded: function(){
var fonts_loaded = new $.Deferred();
// Waiting for fonts to be loaded to prevent receipt printing
// from printing empty receipt while loading Inconsolata
// ( The font used for the receipt )
waitForWebfonts(['Lato','Inconsolata'], function(){
fonts_loaded.resolve();
});
// The JS used to detect font loading is not 100% robust, so
// do not wait more than 5sec
setTimeout(function(){
fonts_loaded.resolve();
},5000);

return fonts_loaded;
},
},{
label: 'pictures',
loaded: function(self){
self.company_logo = new Image();
var logo_loaded = new $.Deferred();
self.company_logo.onload = function(){
var img = self.company_logo;
var ratio = 1;
var targetwidth = 300;
var maxheight = 150;
if( img.width !== targetwidth ){
ratio = targetwidth / img.width;
}
if( img.height * ratio > maxheight ){
ratio = maxheight / img.height;
}
var width = Math.floor(img.width * ratio);
var height = Math.floor(img.height * ratio);
var c = document.createElement('canvas');
c.width = width;
c.height = height;
var ctx = c.getContext('2d');
ctx.drawImage(self.company_logo,0,0, width, height);

self.company_logo_base64 = c.toDataURL();
logo_loaded.resolve();
};
self.company_logo.onerror = function(){
logo_loaded.reject();
};
self.company_logo.crossOrigin = "anonymous";
self.company_logo.src = '/web/binary/company_logo' +'?dbname=' +
session.db + '&_'+Math.random();

return logo_loaded;
},
}, {
label: 'barcodes',
loaded: function(self) {
var barcode_parser = new BarcodeParser({'nomenclature_id':
self.config.barcode_nomenclature_id});
self.barcode_reader.set_barcode_parser(barcode_parser);
return barcode_parser.is_loaded();
},
}
],

// loads all the needed data on the sever. returns a deferred indicating when all
the data has loaded.
load_server_data: function(){
var self = this;
var loaded = new $.Deferred();
var progress = 0;
var progress_step = 1.0 / self.models.length;
var tmp = {}; // this is used to share a temporary state between models
loaders

function load_model(index){
if(index >= self.models.length){
loaded.resolve();
}else{
var model = self.models[index];
self.chrome.loading_message(_t('Loading')+' '+(model.label ||
model.model || ''), progress);

var cond = typeof model.condition === 'function' ?


model.condition(self,tmp) : true;
if (!cond) {
load_model(index+1);
return;
}

var fields = typeof model.fields === 'function' ?


model.fields(self,tmp) : model.fields;
var domain = typeof model.domain === 'function' ?
model.domain(self,tmp) : model.domain;
var context = typeof model.context === 'function' ?
model.context(self,tmp) : model.context;
var ids = typeof model.ids === 'function' ?
model.ids(self,tmp) : model.ids;
var order = typeof model.order === 'function' ?
model.order(self,tmp): model.order;
progress += progress_step;

var records;
if( model.model ){
if (model.ids) {
records = new
Model(model.model).call('read',[ids,fields],context);
} else {
records = new Model(model.model)
.query(fields)
.filter(domain)
.order_by(order)
.context(context)
.all();
}
records.then(function(result){
try{ // catching exceptions in model.loaded(...)
$.when(model.loaded(self,result,tmp))
.then(function(){ load_model(index + 1); },
function(err){ loaded.reject(err); });
}catch(err){
console.error(err.stack);
loaded.reject(err);
}
},function(err){
loaded.reject(err);
});
}else if( model.loaded ){
try{ // catching exceptions in model.loaded(...)
$.when(model.loaded(self,tmp))
.then( function(){ load_model(index +1); },
function(err){ loaded.reject(err); });
}catch(err){
loaded.reject(err);
}
}else{
load_model(index + 1);
}
}
}

try{
load_model(0);
}catch(err){
loaded.reject(err);
}

return loaded;
},

// reload the list of partner, returns as a deferred that resolves if there were
// updated partners, and fails if not
load_new_partners: function(){
var self = this;
var def = new $.Deferred();
var fields = _.find(this.models,function(model){ return model.model ===
'res.partner'; }).fields;
new Model('res.partner')
.query(fields)

.filter([['customer','=',true],['write_date','>',this.db.get_partner_write_date()]])
.all({'timeout':3000, 'shadow': true})
.then(function(partners){
if (self.db.add_partners(partners)) { // check if the partners we
got were real updates
def.resolve();
} else {
def.reject();
}
}, function(err,event){ event.preventDefault(); def.reject(); });
return def;
},

// this is called when an order is removed from the order collection. It ensures
that there is always an existing
// order and a valid selected order
on_removed_order: function(removed_order,index,reason){
var order_list = this.get_order_list();
if( (reason === 'abandon' || removed_order.temporary) && order_list.length >
0){
// when we intentionally remove an unfinished order, and there is another
existing one
this.set_order(order_list[index] || order_list[order_list.length -1]);
}else{
// when the order was automatically removed after completion,
// or when we intentionally delete the only concurrent order
this.add_new_order();
}
},

// returns the user who is currently the cashier for this point of sale
get_cashier: function(){
return this.cashier || this.user;
},
// changes the current cashier
set_cashier: function(user){
this.cashier = user;
},
//creates a new empty order and sets it as the current order
add_new_order: function(){
var order = new exports.Order({},{pos:this});
this.get('orders').add(order);
this.set('selectedOrder', order);
return order;
},
// load the locally saved unpaid orders for this session.
load_orders: function(){
var jsons = this.db.get_unpaid_orders();
var orders = [];
var not_loaded_count = 0;

for (var i = 0; i < jsons.length; i++) {


var json = jsons[i];
if (json.pos_session_id === this.pos_session.id) {
orders.push(new exports.Order({},{
pos: this,
json: json,
}));
} else {
not_loaded_count += 1;
}
}

if (not_loaded_count) {
console.info('There are '+not_loaded_count+' locally saved unpaid orders
belonging to another session');
}

orders = orders.sort(function(a,b){
return a.sequence_number - b.sequence_number;
});

if (orders.length) {
this.get('orders').add(orders);
}
},

set_start_order: function(){
var orders = this.get('orders').models;

if (orders.length && !this.get('selectedOrder')) {


this.set('selectedOrder',orders[0]);
} else {
this.add_new_order();
}
},

// return the current order


get_order: function(){
return this.get('selectedOrder');
},

get_client: function() {
var order = this.get_order();
if (order) {
return order.get_client();
}
return null;
},

// change the current order


set_order: function(order){
this.set({ selectedOrder: order });
},

// return the list of unpaid orders


get_order_list: function(){
return this.get('orders').models;
},

//removes the current order


delete_current_order: function(){
var order = this.get_order();
if (order) {
order.destroy({'reason':'abandon'});
}
},

// saves the order locally and try to send it to the backend.


// it returns a deferred that succeeds after having tried to send the order and
all the other pending orders.
push_order: function(order, opts) {
opts = opts || {};
var self = this;

if(order){
this.db.add_order(order.export_as_JSON());
}

var pushed = new $.Deferred();

this.flush_mutex.exec(function(){
var flushed = self._flush_orders(self.db.get_orders(), opts);

flushed.always(function(ids){
pushed.resolve();
});

return flushed;
});
return pushed;
},

// saves the order locally and try to send it to the backend and make an invoice
// returns a deferred that succeeds when the order has been posted and
successfully generated
// an invoice. This method can fail in various ways:
// error-no-client: the order must have an associated partner_id. You can retry to
make an invoice once
// this error is solved
// error-transfer: there was a connection error during the transfer. You can retry
to make the invoice once
// the network connection is up

push_and_invoice_order: function(order){
var self = this;
var invoiced = new $.Deferred();

if(!order.get_client()){
invoiced.reject({code:400, message:'Missing Customer', data:{}});
return invoiced;
}

var order_id = this.db.add_order(order.export_as_JSON());

this.flush_mutex.exec(function(){
var done = new $.Deferred(); // holds the mutex

// send the order to the server


// we have a 30 seconds timeout on this push.
// FIXME: if the server takes more than 30 seconds to accept the order,
// the client will believe it wasn't successfully sent, and very bad
// things will happen as a duplicate will be sent next time
// so we must make sure the server detects and ignores duplicated orders

var transfer = self._flush_orders([self.db.get_order(order_id)],


{timeout:30000, to_invoice:true});

transfer.fail(function(error){
invoiced.reject(error);
done.reject();
});

// on success, get the order id generated by the server


transfer.pipe(function(order_server_id){

// generate the pdf and download it

self.chrome.do_action('point_of_sale.pos_invoice_report',{additional_context:{
active_ids:order_server_id,
}});

invoiced.resolve();
done.resolve();
});

return done;

});

return invoiced;
},

// wrapper around the _save_to_server that updates the synch status widget
_flush_orders: function(orders, options) {
var self = this;
this.set('synch',{ state: 'connecting', pending: orders.length});

return self._save_to_server(orders, options).done(function (server_ids) {


var pending = self.db.get_orders().length;

self.set('synch', {
state: pending ? 'connecting' : 'connected',
pending: pending
});
return server_ids;
}).fail(function(error, event){
var pending = self.db.get_orders().length;
if (self.get('failed')) {
self.set('synch', { state: 'error', pending: pending });
} else {
self.set('synch', { state: 'disconnected', pending: pending });
}
});
},

// send an array of orders to the server


// available options:
// - timeout: timeout for the rpc call in ms
// returns a deferred that resolves with the list of
// server generated ids for the sent orders
_save_to_server: function (orders, options) {
if (!orders || !orders.length) {
var result = $.Deferred();
result.resolve([]);
return result;
}

options = options || {};

var self = this;


var timeout = typeof options.timeout === 'number' ? options.timeout : 7500 *
orders.length;

// Keep the order ids that are about to be sent to the


// backend. In between create_from_ui and the success callback
// new orders may have been added to it.
var order_ids_to_sync = _.pluck(orders, 'id');

// we try to send the order. shadow prevents a spinner if it takes too long.
(unless we are sending an invoice,
// then we want to notify the user that we are waiting on something )
var posOrderModel = new Model('pos.order');
return posOrderModel.call('create_from_ui',
[_.map(orders, function (order) {
order.to_invoice = options.to_invoice || false;
return order;
})],
undefined,
{
shadow: !options.to_invoice,
timeout: timeout
}
).then(function (server_ids) {
_.each(order_ids_to_sync, function (order_id) {
self.db.remove_order(order_id);
});
self.set('failed',false);
return server_ids;
}).fail(function (error, event){
if(error.code === 200 ){ // Business Logic Error, not a connection
problem
//if warning do not need to display traceback!!
if (error.data.exception_type == 'warning') {
delete error.data.debug;
}
// Hide error if already shown before ...
if ((!self.get('failed') || options.show_error) &&
!options.to_invoice) {
self.gui.show_popup('error-traceback',{
'title': error.data.message,
'body': error.data.debug
});
}
self.set('failed',error)
}
// prevent an error popup creation by the rpc failure
// we want the failure to be silent as we send the orders in the
background
event.preventDefault();
console.error('Failed to send orders:', orders);
});
},

scan_product: function(parsed_code){
var selectedOrder = this.get_order();
var product = this.db.get_product_by_barcode(parsed_code.base_code);

if(!product){
return false;
}

if(parsed_code.type === 'price'){


selectedOrder.add_product(product, {price:parsed_code.value});
}else if(parsed_code.type === 'weight'){
selectedOrder.add_product(product, {quantity:parsed_code.value,
merge:false});
}else if(parsed_code.type === 'discount'){
selectedOrder.add_product(product, {discount:parsed_code.value,
merge:false});
}else{
selectedOrder.add_product(product);
}
return true;
},

// Exports the paid orders (the ones waiting for internet connection)
export_paid_orders: function() {
return JSON.stringify({
'paid_orders': this.db.get_orders(),
'session': this.pos_session.name,
'session_id': this.pos_session.id,
'date': (new Date()).toUTCString(),
'version': this.version.server_version_info,
},null,2);
},

// Exports the unpaid orders (the tabs)


export_unpaid_orders: function() {
return JSON.stringify({
'unpaid_orders': this.db.get_unpaid_orders(),
'session': this.pos_session.name,
'session_id': this.pos_session.id,
'date': (new Date()).toUTCString(),
'version': this.version.server_version_info,
},null,2);
},

// This imports paid or unpaid orders from a json file whose


// contents are provided as the string str.
// It returns a report of what could and what could not be
// imported.
import_orders: function(str) {
var json = JSON.parse(str);
var report = {
// Number of paid orders that were imported
paid: 0,
// Number of unpaid orders that were imported
unpaid: 0,
// Orders that were not imported because they already exist (uid conflict)
unpaid_skipped_existing: 0,
// Orders that were not imported because they belong to another session
unpaid_skipped_session: 0,
// The list of session ids to which skipped orders belong.
unpaid_skipped_sessions: [],
};

if (json.paid_orders) {
for (var i = 0; i < json.paid_orders.length; i++) {
this.db.add_order(json.paid_orders[i].data);
}
report.paid = json.paid_orders.length;
this.push_order();
}

if (json.unpaid_orders) {

var orders = [];


var existing = this.get_order_list();
var existing_uids = {};
var skipped_sessions = {};

for (var i = 0; i < existing.length; i++) {


existing_uids[existing[i].uid] = true;
}

for (var i = 0; i < json.unpaid_orders.length; i++) {


var order = json.unpaid_orders[i];
if (order.pos_session_id !== this.pos_session.id) {
report.unpaid_skipped_session += 1;
skipped_sessions[order.pos_session_id] = true;
} else if (existing_uids[order.uid]) {
report.unpaid_skipped_existing += 1;
} else {
orders.push(new exports.Order({},{
pos: this,
json: order,
}));
}
}

orders = orders.sort(function(a,b){
return a.sequence_number - b.sequence_number;
});

if (orders.length) {
report.unpaid = orders.length;
this.get('orders').add(orders);
}

report.unpaid_skipped_sessions = _.keys(skipped_sessions);
}
return report;
},

_load_orders: function(){
var jsons = this.db.get_unpaid_orders();
var orders = [];
var not_loaded_count = 0;

for (var i = 0; i < jsons.length; i++) {


var json = jsons[i];
if (json.pos_session_id === this.pos_session.id) {
orders.push(new exports.Order({},{
pos: this,
json: json,
}));
} else {
not_loaded_count += 1;
}
}

if (not_loaded_count) {
console.info('There are '+not_loaded_count+' locally saved unpaid orders
belonging to another session');
}

orders = orders.sort(function(a,b){
return a.sequence_number - b.sequence_number;
});

if (orders.length) {
this.get('orders').add(orders);
}
},

});

// Add fields to the list of read fields when a model is loaded


// by the point of sale.
// e.g: module.load_fields("product.product",['price','category'])

exports.load_fields = function(model_name, fields) {


if (!(fields instanceof Array)) {
fields = [fields];
}

var models = exports.PosModel.prototype.models;


for (var i = 0; i < models.length; i++) {
var model = models[i];
if (model.model === model_name) {
// if 'fields' is empty all fields are loaded, so we do not need
// to modify the array
if ((model.fields instanceof Array) && model.fields.length > 0) {
model.fields = model.fields.concat(fields || []);
}
}
}
};

// Loads openerp models at the point of sale startup.


// load_models take an array of model loader declarations.
// - The models will be loaded in the array order.
// - If no openerp model name is provided, no server data
// will be loaded, but the system can be used to preprocess
// data before load.
// - loader arguments can be functions that return a dynamic
// value. The function takes the PosModel as the first argument
// and a temporary object that is shared by all models, and can
// be used to store transient information between model loads.
// - There is no dependency management. The models must be loaded
// in the right order. Newly added models are loaded at the end
// but the after / before options can be used to load directly
// before / after another model.
//
// models: [{
// model: [string] the name of the openerp model to load.
// label: [string] The label displayed during load.
// fields: [[string]|function] the list of fields to be loaded.
// Empty Array / Null loads all fields.
// order: [[string]|function] the models will be ordered by
// the provided fields
// domain: [domain|function] the domain that determines what
// models need to be loaded. Null loads everything
// ids: [[id]|function] the id list of the models that must
// be loaded. Overrides domain.
// context: [Dict|function] the openerp context for the model read
// condition: [function] do not load the models if it evaluates to
// false.
// loaded: [function(self,model)] this function is called once the
// models have been loaded, with the data as second argument
// if the function returns a deferred, the next model will
// wait until it resolves before loading.
// }]
//
// options:
// before: [string] The model will be loaded before the named models
// (applies to both model name and label)
// after: [string] The model will be loaded after the (last loaded)
// named model. (applies to both model name and label)
//
exports.load_models = function(models,options) {
options = options || {};
if (!(models instanceof Array)) {
models = [models];
}

var pmodels = exports.PosModel.prototype.models;


var index = pmodels.length;
if (options.before) {
for (var i = 0; i < pmodels.length; i++) {
if ( pmodels[i].model === options.before ||
pmodels[i].label === options.before ){
index = i;
break;
}
}
} else if (options.after) {
for (var i = 0; i < pmodels.length; i++) {
if ( pmodels[i].model === options.after ||
pmodels[i].label === options.after ){
index = i + 1;
}
}
}
pmodels.splice.apply(pmodels,[index,0].concat(models));
};
var orderline_id = 1;

// An orderline represent one element of the content of a client's shopping cart.


// An orderline contains a product, its quantity, its price, discount. etc.
// An Order contains zero or more Orderlines.
exports.Orderline = Backbone.Model.extend({
initialize: function(attr,options){
this.pos = options.pos;
this.order = options.order;
if (options.json) {
this.init_from_JSON(options.json);
return;
}
this.product = options.product;
this.set_product_lot(this.product)
this.price = options.product.price;
this.set_quantity(1);
this.discount = 0;
this.discountStr = '0';
this.type = 'unit';
this.selected = false;
this.id = orderline_id++;
},
init_from_JSON: function(json) {
this.product = this.pos.db.get_product_by_id(json.product_id);
if (!this.product) {
console.error('ERROR: attempting to recover product ID', json.product_id,
'not available in the point of sale. Correct the product or clean the
browser cache.');
}
this.set_product_lot(this.product)
this.price = json.price_unit;
this.set_discount(json.discount);
this.set_quantity(json.qty);
this.id = json.id;
orderline_id = Math.max(this.id+1,orderline_id);
var pack_lot_lines = json.pack_lot_ids;
for (var i = 0; i < pack_lot_lines.length; i++) {
var packlotline = pack_lot_lines[i][2];
var pack_lot_line = new exports.Packlotline({}, {'json':
_.extend(packlotline, {'order_line':this})});
this.pack_lot_lines.add(pack_lot_line);
}
},
clone: function(){
var orderline = new exports.Orderline({},{
pos: this.pos,
order: this.order,
product: this.product,
price: this.price,
});
orderline.order = null;
orderline.quantity = this.quantity;
orderline.quantityStr = this.quantityStr;
orderline.discount = this.discount;
orderline.type = this.type;
orderline.selected = false;
return orderline;
},
set_product_lot: function(product){
this.has_product_lot = product.tracking !== 'none';
this.pack_lot_lines = this.has_product_lot && new PacklotlineCollection(null,
{'order_line': this});
},
// sets a discount [0,100]%
set_discount: function(discount){
var disc = Math.min(Math.max(parseFloat(discount) || 0, 0),100);
this.discount = disc;
this.discountStr = '' + disc;
this.trigger('change',this);
},
// returns the discount [0,100]%
get_discount: function(){
return this.discount;
},
get_discount_str: function(){
return this.discountStr;
},
get_product_type: function(){
return this.type;
},
// sets the quantity of the product. The quantity will be rounded according to the
// product's unity of measure properties. Quantities greater than zero will not
get
// rounded to zero
set_quantity: function(quantity){
this.order.assert_editable();
if(quantity === 'remove'){
this.order.remove_orderline(this);
return;
}else{
var quant = parseFloat(quantity) || 0;
var unit = this.get_unit();
if(unit){
if (unit.rounding) {
this.quantity = round_pr(quant, unit.rounding);
var decimals = this.pos.dp['Product Unit of Measure'];
this.quantityStr = formats.format_value(round_di(this.quantity,
decimals), { type: 'float', digits: [69, decimals]});
} else {
this.quantity = round_pr(quant, 1);
this.quantityStr = this.quantity.toFixed(0);
}
}else{
this.quantity = quant;
this.quantityStr = '' + this.quantity;
}
}
this.trigger('change',this);
},
// return the quantity of product
get_quantity: function(){
return this.quantity;
},
get_quantity_str: function(){
return this.quantityStr;
},
get_quantity_str_with_unit: function(){
var unit = this.get_unit();
if(unit && !unit.is_unit){
return this.quantityStr + ' ' + unit.name;
}else{
return this.quantityStr;
}
},
compute_lot_lines: function(){
var pack_lot_lines = this.pack_lot_lines;
var lines = pack_lot_lines.length;
if(this.quantity > lines){
for(var i=0; i<this.quantity - lines; i++){
pack_lot_lines.add(new exports.Packlotline({}, {'order_line': this}));
}
}
if(this.quantity < lines){
var to_remove = lines - this.quantity;
var lot_lines = pack_lot_lines.sortBy('lot_name').slice(0, to_remove);
pack_lot_lines.remove(lot_lines);
}
return this.pack_lot_lines;
},

has_valid_product_lot: function(){
if(!this.has_product_lot){
return true;
}
var valid_product_lot = this.pack_lot_lines.get_valid_lots();
return this.quantity === valid_product_lot.length;
},

// return the unit of measure of the product


get_unit: function(){
var unit_id = this.product.uom_id;
if(!unit_id){
return undefined;
}
unit_id = unit_id[0];
if(!this.pos){
return undefined;
}
return this.pos.units_by_id[unit_id];
},
// return the product of this orderline
get_product: function(){
return this.product;
},
// selects or deselects this orderline
set_selected: function(selected){
this.selected = selected;
this.trigger('change',this);
},
// returns true if this orderline is selected
is_selected: function(){
return this.selected;
},
// when we add an new orderline we want to merge it with the last line to see
reduce the number of items
// in the orderline. This returns true if it makes sense to merge the two
can_be_merged_with: function(orderline){
if( this.get_product().id !== orderline.get_product().id){ //only orderline
of the same product can be merged
return false;
}else if(!this.get_unit() || !this.get_unit().groupable){
return false;
}else if(this.get_product_type() !== orderline.get_product_type()){
return false;
}else if(this.get_discount() > 0){ // we don't merge discounted
orderlines
return false;
}else if(this.price !== orderline.price){
return false;
}else{
return true;
}
},
merge: function(orderline){
this.order.assert_editable();
this.set_quantity(this.get_quantity() + orderline.get_quantity());
},
export_as_JSON: function() {
var pack_lot_ids = [];
if (this.has_product_lot){
this.pack_lot_lines.each(_.bind( function(item) {
return pack_lot_ids.push([0, 0, item.export_as_JSON()]);
}, this));
}
return {
qty: this.get_quantity(),
price_unit: this.get_unit_price(),
discount: this.get_discount(),
product_id: this.get_product().id,
tax_ids: [[6, false, _.map(this.get_applicable_taxes(), function(tax){
return tax.id; })]],
id: this.id,
pack_lot_ids: pack_lot_ids
};
},
//used to create a json of the ticket, to be sent to the printer
export_for_printing: function(){
return {
quantity: this.get_quantity(),
unit_name: this.get_unit().name,
price: this.get_unit_display_price(),
discount: this.get_discount(),
product_name: this.get_product().display_name,
product_name_wrapped: this.generate_wrapped_product_name(),
price_display : this.get_display_price(),
price_with_tax : this.get_price_with_tax(),
price_without_tax: this.get_price_without_tax(),
tax: this.get_tax(),
product_description: this.get_product().description,
product_description_sale: this.get_product().description_sale,
};
},
generate_wrapped_product_name: function() {
var MAX_LENGTH = 24; // 40 * line ratio of .6
var wrapped = [];
var name = this.get_product().display_name;
var current_line = "";

while (name.length > 0) {


var space_index = name.indexOf(" ");

if (space_index === -1) {


space_index = name.length;
}

if (current_line.length + space_index > MAX_LENGTH) {


if (current_line.length) {
wrapped.push(current_line);
}
current_line = "";
}

current_line += name.slice(0, space_index + 1);


name = name.slice(space_index + 1);
}

if (current_line.length) {
wrapped.push(current_line);
}

return wrapped;
},
// changes the base price of the product for this orderline
set_unit_price: function(price){
this.order.assert_editable();
this.price = round_di(parseFloat(price) || 0, this.pos.dp['Product Price']);
this.trigger('change',this);
},
get_unit_price: function(){
var digits = this.pos.dp['Product Price'];
// round and truncate to mimic _sybmbol_set behavior
return parseFloat(round_di(this.price || 0, digits).toFixed(digits));
},
get_unit_display_price: function(){
if (this.pos.config.iface_tax_included) {
var quantity = this.quantity;
this.quantity = 1.0;
var price = this.get_all_prices().priceWithTax;
this.quantity = quantity;
return price;
} else {
return this.get_unit_price();
}
},
get_base_price: function(){
var rounding = this.pos.currency.rounding;
return round_pr(this.get_unit_price() * this.get_quantity() * (1 -
this.get_discount()/100), rounding);
},
get_display_price: function(){
if (this.pos.config.iface_tax_included) {
return this.get_price_with_tax();
} else {
return this.get_base_price();
}
},
get_price_without_tax: function(){
return this.get_all_prices().priceWithoutTax;
},
get_price_with_tax: function(){
return this.get_all_prices().priceWithTax;
},
get_tax: function(){
return this.get_all_prices().tax;
},
get_applicable_taxes: function(){
var i;
// Shenaningans because we need
// to keep the taxes ordering.
var ptaxes_ids = this.get_product().taxes_id;
var ptaxes_set = {};
for (i = 0; i < ptaxes_ids.length; i++) {
ptaxes_set[ptaxes_ids[i]] = true;
}
var taxes = [];
for (i = 0; i < this.pos.taxes.length; i++) {
if (ptaxes_set[this.pos.taxes[i].id]) {
taxes.push(this.pos.taxes[i]);
}
}
return taxes;
},
get_tax_details: function(){
return this.get_all_prices().taxDetails;
},
get_taxes: function(){
var taxes_ids = this.get_product().taxes_id;
var taxes = [];
for (var i = 0; i < taxes_ids.length; i++) {
taxes.push(this.pos.taxes_by_id[taxes_ids[i]]);
}
return taxes;
},
_map_tax_fiscal_position: function(tax) {
var current_order = this.pos.get_order();
var order_fiscal_position = current_order && current_order.fiscal_position;

if (order_fiscal_position) {
var mapped_tax = _.find(order_fiscal_position.fiscal_position_taxes_by_id,
function (fiscal_position_tax) {
return fiscal_position_tax.tax_src_id[0] === tax.id;
});

if (mapped_tax) {
tax = this.pos.taxes_by_id[mapped_tax.tax_dest_id[0]];
}
}

return tax;
},
_compute_all: function(tax, base_amount, quantity) {
if (tax.amount_type === 'fixed') {
var sign_base_amount = base_amount >= 0 ? 1 : -1;
return (Math.abs(tax.amount) * sign_base_amount) * quantity;
}
if ((tax.amount_type === 'percent' && !tax.price_include) || (tax.amount_type
=== 'division' && tax.price_include)){
return base_amount * tax.amount / 100;
}
if (tax.amount_type === 'percent' && tax.price_include){
return base_amount - (base_amount / (1 + tax.amount / 100));
}
if (tax.amount_type === 'division' && !tax.price_include) {
return base_amount / (1 - tax.amount / 100) - base_amount;
}
return false;
},
compute_all: function(taxes, price_unit, quantity, currency_rounding, no_map_tax)
{
var self = this;
var list_taxes = [];
var currency_rounding_bak = currency_rounding;
if (this.pos.company.tax_calculation_rounding_method == "round_globally"){
currency_rounding = currency_rounding * 0.00001;
}
var total_excluded = round_pr(price_unit * quantity, currency_rounding);
var total_included = total_excluded;
var base = total_excluded;
_(taxes).each(function(tax) {
if (!no_map_tax){
tax = self._map_tax_fiscal_position(tax);
}
if (tax.amount_type === 'group'){
var ret = self.compute_all(tax.children_tax_ids, price_unit, quantity,
currency_rounding);
total_excluded = ret.total_excluded;
base = ret.total_excluded;
total_included = ret.total_included;
list_taxes = list_taxes.concat(ret.taxes);
}
else {
var tax_amount = self._compute_all(tax, base, quantity);
tax_amount = round_pr(tax_amount, currency_rounding);

if (tax_amount){
if (tax.price_include) {
total_excluded -= tax_amount;
base -= tax_amount;
}
else {
total_included += tax_amount;
}
if (tax.include_base_amount) {
base += tax_amount;
}
var data = {
id: tax.id,
amount: tax_amount,
name: tax.name,
};
list_taxes.push(data);
}
}
});
return {
taxes: list_taxes,
total_excluded: round_pr(total_excluded, currency_rounding_bak),
total_included: round_pr(total_included, currency_rounding_bak)
};
},
get_all_prices: function(){
var price_unit = this.get_unit_price() * (1.0 - (this.get_discount() /
100.0));
var taxtotal = 0;

var product = this.get_product();


var taxes_ids = product.taxes_id;
var taxes = this.pos.taxes;
var taxdetail = {};
var product_taxes = [];

_(taxes_ids).each(function(el){
product_taxes.push(_.detect(taxes, function(t){
return t.id === el;
}));
});

var all_taxes = this.compute_all(product_taxes, price_unit,


this.get_quantity(), this.pos.currency.rounding);
_(all_taxes.taxes).each(function(tax) {
taxtotal += tax.amount;
taxdetail[tax.id] = tax.amount;
});

return {
"priceWithTax": all_taxes.total_included,
"priceWithoutTax": all_taxes.total_excluded,
"tax": taxtotal,
"taxDetails": taxdetail,
};
},
});

var OrderlineCollection = Backbone.Collection.extend({


model: exports.Orderline,
});

exports.Packlotline = Backbone.Model.extend({
defaults: {
lot_name: null
},
initialize: function(attributes, options){
this.order_line = options.order_line;
if (options.json) {
this.init_from_JSON(options.json);
return;
}
},

init_from_JSON: function(json) {
this.order_line = json.order_line;
this.set_lot_name(json.lot_name);
},

set_lot_name: function(name){
this.set({lot_name : _.str.trim(name) || null});
},

get_lot_name: function(){
return this.get('lot_name');
},

export_as_JSON: function(){
return {
lot_name: this.get_lot_name(),
};
},

add: function(){
var order_line = this.order_line,
index = this.collection.indexOf(this);
var new_lot_model = new exports.Packlotline({}, {'order_line':
this.order_line});
this.collection.add(new_lot_model, {at: index + 1});
return new_lot_model;
},

remove: function(){
this.collection.remove(this);
}
});
var PacklotlineCollection = Backbone.Collection.extend({
model: exports.Packlotline,
initialize: function(models, options) {
this.order_line = options.order_line;
},

get_empty_model: function(){
return this.findWhere({'lot_name': null});
},

remove_empty_model: function(){
this.remove(this.where({'lot_name': null}));
},

get_valid_lots: function(){
return this.filter(function(model){
return model.get('lot_name');
});
},

set_quantity_by_lot: function() {
var valid_lots = this.get_valid_lots();
this.order_line.set_quantity(valid_lots.length);
}
});

// Every Paymentline contains a cashregister and an amount of money.


exports.Paymentline = Backbone.Model.extend({
initialize: function(attributes, options) {
this.pos = options.pos;
this.order = options.order;
this.amount = 0;
this.selected = false;
if (options.json) {
this.init_from_JSON(options.json);
return;
}
this.cashregister = options.cashregister;
this.name = this.cashregister.journal_id[1];
},
init_from_JSON: function(json){
this.amount = json.amount;
this.cashregister = this.pos.cashregisters_by_id[json.statement_id];
this.name = this.cashregister.journal_id[1];
},
//sets the amount of money on this payment line
set_amount: function(value){
this.order.assert_editable();
this.amount = round_di(parseFloat(value) || 0, this.pos.currency.decimals);
this.trigger('change',this);
},
// returns the amount of money on this paymentline
get_amount: function(){
return this.amount;
},
get_amount_str: function(){
return formats.format_value(this.amount, {
type: 'float', digits: [69, this.pos.currency.decimals]
});
},
set_selected: function(selected){
if(this.selected !== selected){
this.selected = selected;
this.trigger('change',this);
}
},
// returns the payment type: 'cash' | 'bank'
get_type: function(){
return this.cashregister.journal.type;
},
// returns the associated cashregister
//exports as JSON for server communication
export_as_JSON: function(){
return {
name: time.datetime_to_str(new Date()),
statement_id: this.cashregister.id,
account_id: this.cashregister.account_id[0],
journal_id: this.cashregister.journal_id[0],
amount: this.get_amount()
};
},
//exports as JSON for receipt printing
export_for_printing: function(){
return {
amount: this.get_amount(),
journal: this.cashregister.journal_id[1],
};
},
});

var PaymentlineCollection = Backbone.Collection.extend({


model: exports.Paymentline,
});

// An order more or less represents the content of a client's shopping cart (the
OrderLines)
// plus the associated payment information (the Paymentlines)
// there is always an active ('selected') order in the Pos, a new one is created
// automaticaly once an order is completed and sent to the server.
exports.Order = Backbone.Model.extend({
initialize: function(attributes,options){
Backbone.Model.prototype.initialize.apply(this, arguments);
var self = this;
options = options || {};

this.init_locked = true;
this.pos = options.pos;
this.selected_orderline = undefined;
this.selected_paymentline = undefined;
this.screen_data = {}; // see Gui
this.temporary = options.temporary || false;
this.creation_date = new Date();
this.to_invoice = false;
this.orderlines = new OrderlineCollection();
this.paymentlines = new PaymentlineCollection();
this.pos_session_id = this.pos.pos_session.id;
this.finalized = false; // if true, cannot be modified.

this.set({ client: null });

if (options.json) {
this.init_from_JSON(options.json);
} else {
this.sequence_number = this.pos.pos_session.sequence_number++;
this.uid = this.generate_unique_id();
this.name = _t("Order ") + this.uid;
this.validation_date = undefined;
this.fiscal_position = _.find(this.pos.fiscal_positions, function(fp) {
return fp.id === self.pos.config.default_fiscal_position_id[0];
});
}

this.on('change', function(){ this.save_to_db("order:change"); },


this);
this.orderlines.on('change', function(){
this.save_to_db("orderline:change"); }, this);
this.orderlines.on('add', function(){ this.save_to_db("orderline:add");
}, this);
this.orderlines.on('remove', function(){
this.save_to_db("orderline:remove"); }, this);
this.paymentlines.on('change', function(){
this.save_to_db("paymentline:change"); }, this);
this.paymentlines.on('add', function(){ this.save_to_db("paymentline:add");
}, this);
this.paymentlines.on('remove', function(){ this.save_to_db("paymentline:rem");
}, this);

this.init_locked = false;
this.save_to_db();

return this;
},
save_to_db: function(){
if (!this.temporary && !this.init_locked) {
this.pos.db.save_unpaid_order(this);
}
},
init_from_JSON: function(json) {
var client;
this.sequence_number = json.sequence_number;
this.pos.pos_session.sequence_number =
Math.max(this.sequence_number+1,this.pos.pos_session.sequence_number);
this.session_id = json.pos_session_id;
this.uid = json.uid;
this.name = _t("Order ") + this.uid;
this.validation_date = json.creation_date;

if (json.fiscal_position_id) {
var fiscal_position = _.find(this.pos.fiscal_positions, function (fp) {
return fp.id === json.fiscal_position_id;
});

if (fiscal_position) {
this.fiscal_position = fiscal_position;
} else {
console.error('ERROR: trying to load a fiscal position not available
in the pos');
}
}

if (json.partner_id) {
client = this.pos.db.get_partner_by_id(json.partner_id);
if (!client) {
console.error('ERROR: trying to load a parner not available in the
pos');
}
} else {
client = null;
}
this.set_client(client);

this.temporary = false; // FIXME


this.to_invoice = false; // FIXME

var orderlines = json.lines;


for (var i = 0; i < orderlines.length; i++) {
var orderline = orderlines[i][2];
this.add_orderline(new exports.Orderline({}, {pos: this.pos, order: this,
json: orderline}));
}

var paymentlines = json.statement_ids;


for (var i = 0; i < paymentlines.length; i++) {
var paymentline = paymentlines[i][2];
var newpaymentline = new exports.Paymentline({},{pos: this.pos, order:
this, json: paymentline});
this.paymentlines.add(newpaymentline);

if (i === paymentlines.length - 1) {
this.select_paymentline(newpaymentline);
}
}
},
export_as_JSON: function() {
var orderLines, paymentLines;
orderLines = [];
this.orderlines.each(_.bind( function(item) {
return orderLines.push([0, 0, item.export_as_JSON()]);
}, this));
paymentLines = [];
this.paymentlines.each(_.bind( function(item) {
return paymentLines.push([0, 0, item.export_as_JSON()]);
}, this));
return {
name: this.get_name(),
amount_paid: this.get_total_paid(),
amount_total: this.get_total_with_tax(),
amount_tax: this.get_total_tax(),
amount_return: this.get_change(),
lines: orderLines,
statement_ids: paymentLines,
pos_session_id: this.pos_session_id,
partner_id: this.get_client() ? this.get_client().id : false,
user_id: this.pos.cashier ? this.pos.cashier.id : this.pos.user.id,
uid: this.uid,
sequence_number: this.sequence_number,
creation_date: this.validation_date || this.creation_date, // todo: rename
creation_date in master
fiscal_position_id: this.fiscal_position ? this.fiscal_position.id : false
};
},
export_for_printing: function(){
var orderlines = [];
var self = this;

this.orderlines.each(function(orderline){
orderlines.push(orderline.export_for_printing());
});

var paymentlines = [];


this.paymentlines.each(function(paymentline){
paymentlines.push(paymentline.export_for_printing());
});
var client = this.get('client');
var cashier = this.pos.cashier || this.pos.user;
var company = this.pos.company;
var shop = this.pos.shop;
var date = new Date();

function is_xml(subreceipt){
return subreceipt ? (subreceipt.split('\n')[0].indexOf('<!DOCTYPE QWEB')
>= 0) : false;
}

function render_xml(subreceipt){
if (!is_xml(subreceipt)) {
return subreceipt;
} else {
subreceipt = subreceipt.split('\n').slice(1).join('\n');
var qweb = new QWeb2.Engine();
qweb.debug = core.debug;
qweb.default_dict = _.clone(QWeb.default_dict);
qweb.add_template('<templates><t t-
name="subreceipt">'+subreceipt+'</t></templates>');

return
qweb.render('subreceipt',{'pos':self.pos,'widget':self.pos.chrome,'order':self,
'receipt': receipt}) ;
}
}

var receipt = {
orderlines: orderlines,
paymentlines: paymentlines,
subtotal: this.get_subtotal(),
total_with_tax: this.get_total_with_tax(),
total_without_tax: this.get_total_without_tax(),
total_tax: this.get_total_tax(),
total_paid: this.get_total_paid(),
total_discount: this.get_total_discount(),
tax_details: this.get_tax_details(),
change: this.get_change(),
name : this.get_name(),
client: client ? client.name : null ,
invoice_id: null, //TODO
cashier: cashier ? cashier.name : null,
precision: {
price: 2,
money: 2,
quantity: 3,
},
date: {
year: date.getFullYear(),
month: date.getMonth(),
date: date.getDate(), // day of the month
day: date.getDay(), // day of the week
hour: date.getHours(),
minute: date.getMinutes() ,
isostring: date.toISOString(),
localestring: date.toLocaleString(),
},
company:{
email: company.email,
website: company.website,
company_registry: company.company_registry,
contact_address: company.partner_id[1],
vat: company.vat,
name: company.name,
phone: company.phone,
logo: this.pos.company_logo_base64,
},
shop:{
name: shop.name,
},
currency: this.pos.currency,
};

if (is_xml(this.pos.config.receipt_header)){
receipt.header = '';
receipt.header_xml = render_xml(this.pos.config.receipt_header);
} else {
receipt.header = this.pos.config.receipt_header || '';
}

if (is_xml(this.pos.config.receipt_footer)){
receipt.footer = '';
receipt.footer_xml = render_xml(this.pos.config.receipt_footer);
} else {
receipt.footer = this.pos.config.receipt_footer || '';
}

return receipt;
},
is_empty: function(){
return this.orderlines.models.length === 0;
},
generate_unique_id: function() {
// Generates a public identification number for the order.
// The generated number must be unique and sequential. They are made 12 digit
long
// to fit into EAN-13 barcodes, should it be needed

function zero_pad(num,size){
var s = ""+num;
while (s.length < size) {
s = "0" + s;
}
return s;
}
return zero_pad(this.pos.pos_session.id,5) +'-'+
zero_pad(this.pos.pos_session.login_number,3) +'-'+
zero_pad(this.sequence_number,4);
},
get_name: function() {
return this.name;
},
assert_editable: function() {
if (this.finalized) {
throw new Error('Finalized Order cannot be modified');
}
},
/* ---- Order Lines --- */
add_orderline: function(line){
this.assert_editable();
if(line.order){
line.order.remove_orderline(line);
}
line.order = this;
this.orderlines.add(line);
this.select_orderline(this.get_last_orderline());
},
get_orderline: function(id){
var orderlines = this.orderlines.models;
for(var i = 0; i < orderlines.length; i++){
if(orderlines[i].id === id){
return orderlines[i];
}
}
return null;
},
get_orderlines: function(){
return this.orderlines.models;
},
get_last_orderline: function(){
return this.orderlines.at(this.orderlines.length -1);
},
get_tip: function() {
var tip_product =
this.pos.db.get_product_by_id(this.pos.config.tip_product_id[0]);
var lines = this.get_orderlines();
if (!tip_product) {
return 0;
} else {
for (var i = 0; i < lines.length; i++) {
if (lines[i].get_product() === tip_product) {
return lines[i].get_unit_price();
}
}
return 0;
}
},

initialize_validation_date: function () {
this.validation_date = new Date();
},

set_tip: function(tip) {
var tip_product =
this.pos.db.get_product_by_id(this.pos.config.tip_product_id[0]);
var lines = this.get_orderlines();
if (tip_product) {
for (var i = 0; i < lines.length; i++) {
if (lines[i].get_product() === tip_product) {
lines[i].set_unit_price(tip);
return;
}
}
this.add_product(tip_product, {quantity: 1, price: tip });
}
},
remove_orderline: function( line ){
this.assert_editable();
this.orderlines.remove(line);
this.select_orderline(this.get_last_orderline());
},

fix_tax_included_price: function(line){
if(this.fiscal_position){
var unit_price = line.price;
var taxes = line.get_taxes();
var mapped_included_taxes = [];
_(taxes).each(function(tax) {
var line_tax = line._map_tax_fiscal_position(tax);
if(tax.price_include && tax.id != line_tax.id){

mapped_included_taxes.push(tax);
}
})

unit_price = line.compute_all(mapped_included_taxes, unit_price, 1,


this.pos.currency.rounding, true).total_excluded;

line.set_unit_price(unit_price);
}

},

add_product: function(product, options){


if(this._printed){
this.destroy();
return this.pos.get_order().add_product(product, options);
}
this.assert_editable();
options = options || {};
var attr = JSON.parse(JSON.stringify(product));
attr.pos = this.pos;
attr.order = this;
var line = new exports.Orderline({}, {pos: this.pos, order: this, product:
product});

if(options.quantity !== undefined){


line.set_quantity(options.quantity);
}

if(options.price !== undefined){


line.set_unit_price(options.price);
}

//To substract from the unit price the included taxes mapped by the fiscal
position
this.fix_tax_included_price(line);

if(options.discount !== undefined){


line.set_discount(options.discount);
}

if(options.extras !== undefined){


for (var prop in options.extras) {
line[prop] = options.extras[prop];
}
}

var last_orderline = this.get_last_orderline();


if( last_orderline && last_orderline.can_be_merged_with(line) && options.merge
!== false){
last_orderline.merge(line);
}else{
this.orderlines.add(line);
}
this.select_orderline(this.get_last_orderline());

if(line.has_product_lot){
this.display_lot_popup();
}
},
get_selected_orderline: function(){
return this.selected_orderline;
},
select_orderline: function(line){
if(line){
if(line !== this.selected_orderline){
if(this.selected_orderline){
this.selected_orderline.set_selected(false);
}
this.selected_orderline = line;
this.selected_orderline.set_selected(true);
}
}else{
this.selected_orderline = undefined;
}
},
deselect_orderline: function(){
if(this.selected_orderline){
this.selected_orderline.set_selected(false);
this.selected_orderline = undefined;
}
},

display_lot_popup: function() {
var order_line = this.get_selected_orderline();
if (order_line){
var pack_lot_lines = order_line.compute_lot_lines();
this.pos.gui.show_popup('packlotline', {
'title': _t('Lot/Serial Number(s) Required'),
'pack_lot_lines': pack_lot_lines,
'order': this
});
}
},

/* ---- Payment Lines --- */


add_paymentline: function(cashregister) {
this.assert_editable();
var newPaymentline = new exports.Paymentline({},{order: this,
cashregister:cashregister, pos: this.pos});
if(cashregister.journal.type !== 'cash' ||
this.pos.config.iface_precompute_cash){
newPaymentline.set_amount( Math.max(this.get_due(),0) );
}
this.paymentlines.add(newPaymentline);
this.select_paymentline(newPaymentline);

},
get_paymentlines: function(){
return this.paymentlines.models;
},
remove_paymentline: function(line){
this.assert_editable();
if(this.selected_paymentline === line){
this.select_paymentline(undefined);
}
this.paymentlines.remove(line);
},
clean_empty_paymentlines: function() {
var lines = this.paymentlines.models;
var empty = [];
for ( var i = 0; i < lines.length; i++) {
if (!lines[i].get_amount()) {
empty.push(lines[i]);
}
}
for ( var i = 0; i < empty.length; i++) {
this.remove_paymentline(empty[i]);
}
},
select_paymentline: function(line){
if(line !== this.selected_paymentline){
if(this.selected_paymentline){
this.selected_paymentline.set_selected(false);
}
this.selected_paymentline = line;
if(this.selected_paymentline){
this.selected_paymentline.set_selected(true);
}
this.trigger('change:selected_paymentline',this.selected_paymentline);
}
},
/* ---- Payment Status --- */
get_subtotal : function(){
return round_pr(this.orderlines.reduce((function(sum, orderLine){
return sum + orderLine.get_display_price();
}), 0), this.pos.currency.rounding);
},
get_total_with_tax: function() {
return this.get_total_without_tax() + this.get_total_tax();
},
get_total_without_tax: function() {
return round_pr(this.orderlines.reduce((function(sum, orderLine) {
return sum + orderLine.get_price_without_tax();
}), 0), this.pos.currency.rounding);
},
get_total_discount: function() {
return round_pr(this.orderlines.reduce((function(sum, orderLine) {
return sum + (orderLine.get_unit_price() * (orderLine.get_discount()/100)
* orderLine.get_quantity());
}), 0), this.pos.currency.rounding);
},
get_total_tax: function() {
return round_pr(this.orderlines.reduce((function(sum, orderLine) {
return sum + orderLine.get_tax();
}), 0), this.pos.currency.rounding);
},
get_total_paid: function() {
return round_pr(this.paymentlines.reduce((function(sum, paymentLine) {
return sum + paymentLine.get_amount();
}), 0), this.pos.currency.rounding);
},
get_tax_details: function(){
var details = {};
var fulldetails = [];

this.orderlines.each(function(line){
var ldetails = line.get_tax_details();
for(var id in ldetails){
if(ldetails.hasOwnProperty(id)){
details[id] = (details[id] || 0) + ldetails[id];
}
}
});
for(var id in details){
if(details.hasOwnProperty(id)){
fulldetails.push({amount: details[id], tax: this.pos.taxes_by_id[id],
name: this.pos.taxes_by_id[id].name});
}
}

return fulldetails;
},
// Returns a total only for the orderlines with products belonging to the category
get_total_for_category_with_tax: function(categ_id){
var total = 0;
var self = this;

if (categ_id instanceof Array) {


for (var i = 0; i < categ_id.length; i++) {
total += this.get_total_for_category_with_tax(categ_id[i]);
}
return total;
}

this.orderlines.each(function(line){
if ( self.pos.db.category_contains(categ_id,line.product.id) ) {
total += line.get_price_with_tax();
}
});

return total;
},
get_total_for_taxes: function(tax_id){
var total = 0;

if (!(tax_id instanceof Array)) {


tax_id = [tax_id];
}

var tax_set = {};

for (var i = 0; i < tax_id.length; i++) {


tax_set[tax_id[i]] = true;
}

this.orderlines.each(function(line){
var taxes_ids = line.get_product().taxes_id;
for (var i = 0; i < taxes_ids.length; i++) {
if (tax_set[taxes_ids[i]]) {
total += line.get_price_with_tax();
return;
}
}
});

return total;
},
get_change: function(paymentline) {
if (!paymentline) {
var change = this.get_total_paid() - this.get_total_with_tax();
} else {
var change = -this.get_total_with_tax();
var lines = this.paymentlines.models;
for (var i = 0; i < lines.length; i++) {
change += lines[i].get_amount();
if (lines[i] === paymentline) {
break;
}
}
}
return round_pr(Math.max(0,change), this.pos.currency.rounding);
},
get_due: function(paymentline) {
if (!paymentline) {
var due = this.get_total_with_tax() - this.get_total_paid();
} else {
var due = this.get_total_with_tax();
var lines = this.paymentlines.models;
for (var i = 0; i < lines.length; i++) {
if (lines[i] === paymentline) {
break;
} else {
due -= lines[i].get_amount();
}
}
}
return round_pr(Math.max(0,due), this.pos.currency.rounding);
},
is_paid: function(){
return this.get_due() === 0;
},
is_paid_with_cash: function(){
return !!this.paymentlines.find( function(pl){
return pl.cashregister.journal.type === 'cash';
});
},
finalize: function(){
this.destroy();
},
destroy: function(){
Backbone.Model.prototype.destroy.apply(this,arguments);
this.pos.db.remove_unpaid_order(this);
},
/* ---- Invoice --- */
set_to_invoice: function(to_invoice) {
this.assert_editable();
this.to_invoice = to_invoice;
},
is_to_invoice: function(){
return this.to_invoice;
},
/* ---- Client / Customer --- */
// the client related to the current order.
set_client: function(client){
this.assert_editable();
this.set('client',client);
},
get_client: function(){
return this.get('client');
},
get_client_name: function(){
var client = this.get('client');
return client ? client.name : "";
},
/* ---- Screen Status --- */
// the order also stores the screen status, as the PoS supports
// different active screens per order. This method is used to
// store the screen status.
set_screen_data: function(key,value){
if(arguments.length === 2){
this.screen_data[key] = value;
}else if(arguments.length === 1){
for(var key in arguments[0]){
this.screen_data[key] = arguments[0][key];
}
}
},
//see set_screen_data
get_screen_data: function(key){
return this.screen_data[key];
},
});

var OrderCollection = Backbone.Collection.extend({


model: exports.Order,
});

/*
The numpad handles both the choice of the property currently being modified
(quantity, price or discount) and the edition of the corresponding numeric value.
*/
exports.NumpadState = Backbone.Model.extend({
defaults: {
buffer: "0",
mode: "quantity"
},
appendNewChar: function(newChar) {
var oldBuffer;
oldBuffer = this.get('buffer');
if (oldBuffer === '0') {
this.set({
buffer: newChar
});
} else if (oldBuffer === '-0') {
this.set({
buffer: "-" + newChar
});
} else {
this.set({
buffer: (this.get('buffer')) + newChar
});
}
this.trigger('set_value',this.get('buffer'));
},
deleteLastChar: function() {
if(this.get('buffer') === ""){
if(this.get('mode') === 'quantity'){
this.trigger('set_value','remove');
}else{
this.trigger('set_value',this.get('buffer'));
}
}else{
var newBuffer = this.get('buffer').slice(0,-1) || "";
this.set({ buffer: newBuffer });
this.trigger('set_value',this.get('buffer'));
}
},
switchSign: function() {
var oldBuffer;
oldBuffer = this.get('buffer');
this.set({
buffer: oldBuffer[0] === '-' ? oldBuffer.substr(1) : "-" + oldBuffer
});
this.trigger('set_value',this.get('buffer'));
},
changeMode: function(newMode) {
this.set({
buffer: "0",
mode: newMode
});
},
reset: function() {
this.set({
buffer: "0",
mode: "quantity"
});
},
resetValue: function(){
this.set({buffer:'0'});
},
});

// exports = {
// PosModel: PosModel,
// NumpadState: NumpadState,
// load_fields: load_fields,
// load_models: load_models,
// Orderline: Orderline,
// Order: Order,
// };
return exports;

});
POS Screens JS
odoo.define('point_of_sale.screens', function (require) {
"use strict";
// This file contains the Screens definitions. Screens are the
// content of the right pane of the pos, containing the main functionalities.
//
// Screens must be defined and named in chrome.js before use.
//
// Screens transitions are controlled by the Gui.
// gui.set_startup_screen() sets the screen displayed at startup
// gui.set_default_screen() sets the screen displayed for new orders
// gui.show_screen() shows a screen
// gui.back() goes to the previous screen
//
// Screen state is saved in the order. When a new order is selected,
// a screen is displayed based on the state previously saved in the order.
// this is also done in the Gui with:
// gui.show_saved_screen()
//
// All screens inherit from ScreenWidget. The only addition from the base widgets
// are show() and hide() which shows and hides the screen but are also used to
// bind and unbind actions on widgets and devices. The gui guarantees
// that only one screen is shown at the same time and that show() is called after all
// hide()s
//
// Each Screens must be independant from each other, and should have no
// persistent state outside the models. Screen state variables are reset at
// each screen display. A screen can be called with parameters, which are
// to be used for the duration of the screen only.

var PosBaseWidget = require('point_of_sale.BaseWidget');


var gui = require('point_of_sale.gui');
var models = require('point_of_sale.models');
var core = require('web.core');
var Model = require('web.DataModel');
var utils = require('web.utils');
var formats = require('web.formats');

var QWeb = core.qweb;


var _t = core._t;

var round_pr = utils.round_precision;

/*--------------------------------------*\
| THE SCREEN WIDGET |
\*======================================*/

// The screen widget is the base class inherited


// by all screens.
var ScreenWidget = PosBaseWidget.extend({

init: function(parent,options){
this._super(parent,options);
this.hidden = false;
},

barcode_product_screen: 'products', //if defined, this screen will be


loaded when a product is scanned

// what happens when a product is scanned :


// it will add the product to the order and go to barcode_product_screen.
barcode_product_action: function(code){
var self = this;
if (self.pos.scan_product(code)) {
if (self.barcode_product_screen) {
self.gui.show_screen(self.barcode_product_screen, null, null, true);
}
} else {
this.barcode_error_action(code);
}
},

// what happens when a cashier id barcode is scanned.


// the default behavior is the following :
// - if there's a user with a matching barcode, put it as the active 'cashier', go
to cashier mode, and return true
// - else : do nothing and return false. You probably want to extend this to show
and appropriate error popup...
barcode_cashier_action: function(code){
var users = this.pos.users;
for(var i = 0, len = users.length; i < len; i++){
if(users[i].barcode === code.code){
this.pos.set_cashier(users[i]);
this.chrome.widget.username.renderElement();
return true;
}
}
this.barcode_error_action(code);
return false;
},

// what happens when a client id barcode is scanned.


// the default behavior is the following :
// - if there's a user with a matching barcode, put it as the active 'client' and
return true
// - else : return false.
barcode_client_action: function(code){
var partner = this.pos.db.get_partner_by_barcode(code.code);
if(partner){
this.pos.get_order().set_client(partner);
return true;
}
this.barcode_error_action(code);
return false;
},

// what happens when a discount barcode is scanned : the default behavior


// is to set the discount on the last order.
barcode_discount_action: function(code){
var last_orderline = this.pos.get_order().get_last_orderline();
if(last_orderline){
last_orderline.set_discount(code.value);
}
},
// What happens when an invalid barcode is scanned : shows an error popup.
barcode_error_action: function(code) {
var show_code;
if (code.code.length > 32) {
show_code = code.code.substring(0,29)+'...';
} else {
show_code = code.code;
}
this.gui.show_popup('error-barcode',show_code);
},
// this method shows the screen and sets up all the widget related to this screen.
Extend this method
// if you want to alter the behavior of the screen.
show: function(){
var self = this;

this.hidden = false;
if(this.$el){
this.$el.removeClass('oe_hidden');
}

this.pos.barcode_reader.set_action_callback({
'cashier': _.bind(self.barcode_cashier_action, self),
'product': _.bind(self.barcode_product_action, self),
'weight': _.bind(self.barcode_product_action, self),
'price': _.bind(self.barcode_product_action, self),
'client' : _.bind(self.barcode_client_action, self),
'discount': _.bind(self.barcode_discount_action, self),
'error' : _.bind(self.barcode_error_action, self),
});
},

// this method is called when the screen is closed to make place for a new screen.
this is a good place
// to put your cleanup stuff as it is guaranteed that for each show() there is one
and only one close()
close: function(){
if(this.pos.barcode_reader){
this.pos.barcode_reader.reset_action_callbacks();
}
},

// this methods hides the screen. It's not a good place to put your cleanup stuff
as it is called on the
// POS initialization.
hide: function(){
this.hidden = true;
if(this.$el){
this.$el.addClass('oe_hidden');
}
},

// we need this because some screens re-render themselves when they are hidden
// (due to some events, or magic, or both...) we must make sure they remain
hidden.
// the good solution would probably be to make them not re-render themselves when
they
// are hidden.
renderElement: function(){
this._super();
if(this.hidden){
if(this.$el){
this.$el.addClass('oe_hidden');
}
}
},
});

/*--------------------------------------*\
| THE DOM CACHE |
\*======================================*/
// The Dom Cache is used by various screens to improve
// their performances when displaying many time the
// same piece of DOM.
//
// It is a simple map from string 'keys' to DOM Nodes.
//
// The cache empties itself based on usage frequency
// stats, so you may not always get back what
// you put in.

var DomCache = core.Class.extend({


init: function(options){
options = options || {};
this.max_size = options.max_size || 2000;

this.cache = {};
this.access_time = {};
this.size = 0;
},
cache_node: function(key,node){
var cached = this.cache[key];
this.cache[key] = node;
this.access_time[key] = new Date().getTime();
if(!cached){
this.size++;
while(this.size >= this.max_size){
var oldest_key = null;
var oldest_time = new Date().getTime();
for(key in this.cache){
var time = this.access_time[key];
if(time <= oldest_time){
oldest_time = time;
oldest_key = key;
}
}
if(oldest_key){
delete this.cache[oldest_key];
delete this.access_time[oldest_key];
}
this.size--;
}
}
return node;
},
clear_node: function(key) {
var cached = this.cache[key];
if (cached) {
delete this.cache[key];
delete this.access_time[key];
this.size --;
}
},
get_node: function(key){
var cached = this.cache[key];
if(cached){
this.access_time[key] = new Date().getTime();
}
return cached;
},
});

/*--------------------------------------*\
| THE SCALE SCREEN |
\*======================================*/

// The scale screen displays the weight of


// a product on the electronic scale.

var ScaleScreenWidget = ScreenWidget.extend({


template:'ScaleScreenWidget',

next_screen: 'products',
previous_screen: 'products',

show: function(){
this._super();
var self = this;
var queue = this.pos.proxy_queue;

this.set_weight(0);
this.renderElement();

this.hotkey_handler = function(event){
if(event.which === 13){
self.order_product();
self.gui.show_screen(self.next_screen);
}else if(event.which === 27){
self.gui.show_screen(self.previous_screen);
}
};

$('body').on('keypress',this.hotkey_handler);

this.$('.back').click(function(){
self.gui.show_screen(self.previous_screen);
});

this.$('.next,.buy-product').click(function(){
self.gui.show_screen(self.next_screen);
// add product *after* switching screen to scroll properly
self.order_product();
});

queue.schedule(function(){
return self.pos.proxy.scale_read().then(function(weight){
self.set_weight(weight.weight);
});
},{duration:150, repeat: true});

},
get_product: function(){
return this.gui.get_current_screen_param('product');
},
order_product: function(){
this.pos.get_order().add_product(this.get_product(),{ quantity: this.weight
});
},
get_product_name: function(){
var product = this.get_product();
return (product ? product.display_name : undefined) || 'Unnamed Product';
},
get_product_price: function(){
var product = this.get_product();
return (product ? product.price : 0) || 0;
},
get_product_uom: function(){
var product = this.get_product();

if(product){
return this.pos.units_by_id[product.uom_id[0]].name;
}else{
return '';
}
},
set_weight: function(weight){
this.weight = weight;
this.$('.weight').text(this.get_product_weight_string());
this.$('.computed-price').text(this.get_computed_price_string());
},
get_product_weight_string: function(){
var product = this.get_product();
var defaultstr = (this.weight || 0).toFixed(3) + ' Kg';
if(!product || !this.pos){
return defaultstr;
}
var unit_id = product.uom_id;
if(!unit_id){
return defaultstr;
}
var unit = this.pos.units_by_id[unit_id[0]];
var weight = round_pr(this.weight || 0, unit.rounding);
var weightstr = weight.toFixed(Math.ceil(Math.log(1.0/unit.rounding) /
Math.log(10) ));
weightstr += ' ' + unit.name;
return weightstr;
},
get_computed_price_string: function(){
return this.format_currency(this.get_product_price() * this.weight);
},
close: function(){
this._super();
$('body').off('keypress',this.hotkey_handler);

this.pos.proxy_queue.clear();
},
});
gui.define_screen({name: 'scale', widget: ScaleScreenWidget});

/*--------------------------------------*\
| THE PRODUCT SCREEN |
\*======================================*/

// The product screen contains the list of products,


// The category selector and the order display.
// It is the default screen for orders and the
// startup screen for shops.
//
// There product screens uses many sub-widgets,
// the code follows.

/* ------------ The Numpad ------------ */

// The numpad that edits the order lines.

var NumpadWidget = PosBaseWidget.extend({


template:'NumpadWidget',
init: function(parent) {
this._super(parent);
this.state = new models.NumpadState();
},
start: function() {
this.state.bind('change:mode', this.changedMode, this);
this.changedMode();
this.$el.find('.numpad-backspace').click(_.bind(this.clickDeleteLastChar,
this));
this.$el.find('.numpad-minus').click(_.bind(this.clickSwitchSign, this));
this.$el.find('.number-char').click(_.bind(this.clickAppendNewChar, this));
this.$el.find('.mode-button').click(_.bind(this.clickChangeMode, this));
},
clickDeleteLastChar: function() {
return this.state.deleteLastChar();
},
clickSwitchSign: function() {
return this.state.switchSign();
},
clickAppendNewChar: function(event) {
var newChar;
newChar = event.currentTarget.innerText || event.currentTarget.textContent;
return this.state.appendNewChar(newChar);
},
clickChangeMode: function(event) {
var newMode = event.currentTarget.attributes['data-mode'].nodeValue;
return this.state.changeMode(newMode);
},
changedMode: function() {
var mode = this.state.get('mode');
$('.selected-mode').removeClass('selected-mode');
$(_.str.sprintf('.mode-button[data-mode="%s"]', mode),
this.$el).addClass('selected-mode');
},
});

/* ---------- The Action Pad ---------- */

// The action pad contains the payment button and the


// customer selection button

var ActionpadWidget = PosBaseWidget.extend({


template: 'ActionpadWidget',
init: function(parent, options) {
var self = this;
this._super(parent, options);

this.pos.bind('change:selectedClient', function() {
self.renderElement();
});
},
renderElement: function() {
var self = this;
this._super();
this.$('.pay').click(function(){
var order = self.pos.get_order();
var has_valid_product_lot = _.every(order.orderlines.models,
function(line){
return line.has_valid_product_lot();
});
if(!has_valid_product_lot){
self.gui.show_popup('confirm',{
'title': _t('Empty Serial/Lot Number'),
'body': _t('One or more product(s) required serial/lot number.'),
confirm: function(){
self.gui.show_screen('payment');
},
});
}else{
self.gui.show_screen('payment');
}
});
this.$('.set-customer').click(function(){
self.gui.show_screen('clientlist');
});
}
});

/* --------- The Order Widget --------- */

// Displays the current Order.

var OrderWidget = PosBaseWidget.extend({


template:'OrderWidget',
init: function(parent, options) {
var self = this;
this._super(parent,options);

this.numpad_state = options.numpad_state;
this.numpad_state.reset();
this.numpad_state.bind('set_value', this.set_value, this);

this.pos.bind('change:selectedOrder', this.change_selected_order, this);

this.line_click_handler = function(event){
self.click_line(this.orderline, event);
};

if (this.pos.get_order()) {
this.bind_order_events();
}

},
click_line: function(orderline, event) {
this.pos.get_order().select_orderline(orderline);
this.numpad_state.reset();
},

set_value: function(val) {
var order = this.pos.get_order();
if (order.get_selected_orderline()) {
var mode = this.numpad_state.get('mode');
if( mode === 'quantity'){
order.get_selected_orderline().set_quantity(val);
}else if( mode === 'discount'){
order.get_selected_orderline().set_discount(val);
}else if( mode === 'price'){
order.get_selected_orderline().set_unit_price(val);
}
}
},
change_selected_order: function() {
if (this.pos.get_order()) {
this.bind_order_events();
this.numpad_state.reset();
this.renderElement();
}
},
orderline_add: function(){
this.numpad_state.reset();
this.renderElement('and_scroll_to_bottom');
},
orderline_remove: function(line){
this.remove_orderline(line);
this.numpad_state.reset();
this.update_summary();
},
orderline_change: function(line){
this.rerender_orderline(line);
this.update_summary();
},
bind_order_events: function() {
var order = this.pos.get_order();
order.unbind('change:client', this.update_summary, this);
order.bind('change:client', this.update_summary, this);
order.unbind('change', this.update_summary, this);
order.bind('change', this.update_summary, this);

var lines = order.orderlines;


lines.unbind('add', this.orderline_add, this);
lines.bind('add', this.orderline_add, this);
lines.unbind('remove', this.orderline_remove, this);
lines.bind('remove', this.orderline_remove, this);
lines.unbind('change', this.orderline_change, this);
lines.bind('change', this.orderline_change, this);

},
render_orderline: function(orderline){
var el_str = QWeb.render('Orderline',{widget:this, line:orderline});
var el_node = document.createElement('div');
el_node.innerHTML = _.str.trim(el_str);
el_node = el_node.childNodes[0];
el_node.orderline = orderline;
el_node.addEventListener('click',this.line_click_handler);
var el_lot_icon = el_node.querySelector('.line-lot-icon');
if(el_lot_icon){
el_lot_icon.addEventListener('click', (function() {
this.show_product_lot(orderline);
}.bind(this)));
}

orderline.node = el_node;
return el_node;
},
remove_orderline: function(order_line){
if(this.pos.get_order().get_orderlines().length === 0){
this.renderElement();
}else{
order_line.node.parentNode.removeChild(order_line.node);
}
},
rerender_orderline: function(order_line){
var node = order_line.node;
var replacement_line = this.render_orderline(order_line);
node.parentNode.replaceChild(replacement_line,node);
},
// overriding the openerp framework replace method for performance reasons
replace: function($target){
this.renderElement();
var target = $target[0];
target.parentNode.replaceChild(this.el,target);
},
renderElement: function(scrollbottom){
var order = this.pos.get_order();
if (!order) {
return;
}
var orderlines = order.get_orderlines();

var el_str = QWeb.render('OrderWidget',{widget:this, order:order,


orderlines:orderlines});

var el_node = document.createElement('div');


el_node.innerHTML = _.str.trim(el_str);
el_node = el_node.childNodes[0];

var list_container = el_node.querySelector('.orderlines');


for(var i = 0, len = orderlines.length; i < len; i++){
var orderline = this.render_orderline(orderlines[i]);
list_container.appendChild(orderline);
}

if(this.el && this.el.parentNode){


this.el.parentNode.replaceChild(el_node,this.el);
}
this.el = el_node;
this.update_summary();

if(scrollbottom){
this.el.querySelector('.order-scroller').scrollTop = 100 *
orderlines.length;
}
},
update_summary: function(){
var order = this.pos.get_order();
if (!order.get_orderlines().length) {
return;
}

var total = order ? order.get_total_with_tax() : 0;


var taxes = order ? total - order.get_total_without_tax() : 0;

this.el.querySelector('.summary .total > .value').textContent =


this.format_currency(total);
this.el.querySelector('.summary .total .subentry .value').textContent =
this.format_currency(taxes);
},
show_product_lot: function(orderline){
this.pos.get_order().select_orderline(orderline);
var order = this.pos.get_order();
order.display_lot_popup();
},
});

/* ------ The Product Categories ------ */

// Display and navigate the product categories.


// Also handles searches.
// - set_category() to change the displayed category
// - reset_category() to go to the root category
// - perform_search() to search for products
// - clear_search() does what it says.
var ProductCategoriesWidget = PosBaseWidget.extend({
template: 'ProductCategoriesWidget',
init: function(parent, options){
var self = this;
this._super(parent,options);
this.product_type = options.product_type || 'all'; // 'all' | 'weightable'
this.onlyWeightable = options.onlyWeightable || false;
this.category = this.pos.root_category;
this.breadcrumb = [];
this.subcategories = [];
this.product_list_widget = options.product_list_widget || null;
this.category_cache = new DomCache();
this.start_categ_id = this.pos.config.iface_start_categ_id ?
this.pos.config.iface_start_categ_id[0] : 0;
this.set_category(this.pos.db.get_category_by_id(this.start_categ_id));

this.switch_category_handler = function(event){

self.set_category(self.pos.db.get_category_by_id(Number(this.dataset.categoryId)));
self.renderElement();
};

this.clear_search_handler = function(event){
self.clear_search();
};

var search_timeout = null;


this.search_handler = function(event){
if(event.type == "keypress" || event.keyCode === 46 || event.keyCode ===
8){
clearTimeout(search_timeout);

var searchbox = this;

search_timeout = setTimeout(function(){
self.perform_search(self.category, searchbox.value, event.which
=== 13);
},70);
}
};
},

// changes the category. if undefined, sets to root category


set_category : function(category){
var db = this.pos.db;
if(!category){
this.category = db.get_category_by_id(db.root_category_id);
}else{
this.category = category;
}
this.breadcrumb = [];
var ancestors_ids = db.get_category_ancestors_ids(this.category.id);
for(var i = 1; i < ancestors_ids.length; i++){
this.breadcrumb.push(db.get_category_by_id(ancestors_ids[i]));
}
if(this.category.id !== db.root_category_id){
this.breadcrumb.push(this.category);
}
this.subcategories =
db.get_category_by_id(db.get_category_childs_ids(this.category.id));
},
get_image_url: function(category){
return window.location.origin +
'/web/image?model=pos.category&field=image_medium&id='+category.id;
},

render_category: function( category, with_image ){


var cached = this.category_cache.get_node(category.id);
if(!cached){
if(with_image){
var image_url = this.get_image_url(category);
var category_html = QWeb.render('CategoryButton',{
widget: this,
category: category,
image_url: this.get_image_url(category),
});
category_html = _.str.trim(category_html);
var category_node = document.createElement('div');
category_node.innerHTML = category_html;
category_node = category_node.childNodes[0];
}else{
var category_html = QWeb.render('CategorySimpleButton',{
widget: this,
category: category,
});
category_html = _.str.trim(category_html);
var category_node = document.createElement('div');
category_node.innerHTML = category_html;
category_node = category_node.childNodes[0];
}
this.category_cache.cache_node(category.id,category_node);
return category_node;
}
return cached;
},

replace: function($target){
this.renderElement();
var target = $target[0];
target.parentNode.replaceChild(this.el,target);
},

renderElement: function(){

var el_str = QWeb.render(this.template, {widget: this});


var el_node = document.createElement('div');

el_node.innerHTML = el_str;
el_node = el_node.childNodes[1];

if(this.el && this.el.parentNode){


this.el.parentNode.replaceChild(el_node,this.el);
}

this.el = el_node;

var withpics = this.pos.config.iface_display_categ_images;

var list_container = el_node.querySelector('.category-list');


if (list_container) {
if (!withpics) {
list_container.classList.add('simple');
} else {
list_container.classList.remove('simple');
}
for(var i = 0, len = this.subcategories.length; i < len; i++){

list_container.appendChild(this.render_category(this.subcategories[i],withpics));
}
}

var buttons = el_node.querySelectorAll('.js-category-switch');


for(var i = 0; i < buttons.length; i++){
buttons[i].addEventListener('click',this.switch_category_handler);
}

var products = this.pos.db.get_product_by_category(this.category.id);


this.product_list_widget.set_product_list(products); // FIXME: this should be
moved elsewhere ...

this.el.querySelector('.searchbox
input').addEventListener('keypress',this.search_handler);

this.el.querySelector('.searchbox
input').addEventListener('keydown',this.search_handler);

this.el.querySelector('.search-
clear').addEventListener('click',this.clear_search_handler);

if(this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard){


this.chrome.widget.keyboard.connect($(this.el.querySelector('.searchbox
input')));
}
},

// resets the current category to the root category


reset_category: function(){
this.set_category(this.pos.db.get_category_by_id(this.start_categ_id));
this.renderElement();
},

// empties the content of the search box


clear_search: function(){
var products = this.pos.db.get_product_by_category(this.category.id);
this.product_list_widget.set_product_list(products);
var input = this.el.querySelector('.searchbox input');
input.value = '';
input.focus();
},
perform_search: function(category, query, buy_result){
var products;
if(query){
products = this.pos.db.search_product_in_category(category.id,query);
if(buy_result && products.length === 1){
this.pos.get_order().add_product(products[0]);
this.clear_search();
}else{
this.product_list_widget.set_product_list(products);
}
}else{
products = this.pos.db.get_product_by_category(this.category.id);
this.product_list_widget.set_product_list(products);
}
},

});
/* --------- The Product List --------- */

// Display the list of products.


// - change the list with .set_product_list()
// - click_product_action(), passed as an option, tells
// what to do when a product is clicked.

var ProductListWidget = PosBaseWidget.extend({


template:'ProductListWidget',
init: function(parent, options) {
var self = this;
this._super(parent,options);
this.model = options.model;
this.productwidgets = [];
this.weight = options.weight || 0;
this.show_scale = options.show_scale || false;
this.next_screen = options.next_screen || false;

this.click_product_handler = function(){
var product = self.pos.db.get_product_by_id(this.dataset.productId);
options.click_product_action(product);
};

this.product_list = options.product_list || [];


this.product_cache = new DomCache();
},
set_product_list: function(product_list){
this.product_list = product_list;
this.renderElement();
},
get_product_image_url: function(product){
return window.location.origin +
'/web/image?model=product.product&field=image_medium&id='+product.id;
},
replace: function($target){
this.renderElement();
var target = $target[0];
target.parentNode.replaceChild(this.el,target);
},

render_product: function(product){
var cached = this.product_cache.get_node(product.id);
if(!cached){
var image_url = this.get_product_image_url(product);
var product_html = QWeb.render('Product',{
widget: this,
product: product,
image_url: this.get_product_image_url(product),
});
var product_node = document.createElement('div');
product_node.innerHTML = product_html;
product_node = product_node.childNodes[1];
this.product_cache.cache_node(product.id,product_node);
return product_node;
}
return cached;
},

renderElement: function() {
var el_str = QWeb.render(this.template, {widget: this});
var el_node = document.createElement('div');
el_node.innerHTML = el_str;
el_node = el_node.childNodes[1];
if(this.el && this.el.parentNode){
this.el.parentNode.replaceChild(el_node,this.el);
}
this.el = el_node;

var list_container = el_node.querySelector('.product-list');


for(var i = 0, len = this.product_list.length; i < len; i++){
var product_node = this.render_product(this.product_list[i]);
product_node.addEventListener('click',this.click_product_handler);
list_container.appendChild(product_node);
}
},
});

/* -------- The Action Buttons -------- */

// Above the numpad and the actionpad, buttons


// for extra actions and controls by point of
// sale extensions modules.

var action_button_classes = [];


var define_action_button = function(classe, options){
options = options || {};

var classes = action_button_classes;


var index = classes.length;
var i;

if (options.after) {
for (i = 0; i < classes.length; i++) {
if (classes[i].name === options.after) {
index = i + 1;
}
}
} else if (options.before) {
for (i = 0; i < classes.length; i++) {
if (classes[i].name === options.after) {
index = i;
break;
}
}
}
classes.splice(i,0,classe);
};

var ActionButtonWidget = PosBaseWidget.extend({


template: 'ActionButtonWidget',
label: _t('Button'),
renderElement: function(){
var self = this;
this._super();
this.$el.click(function(){
self.button_click();
});
},
button_click: function(){},
highlight: function(highlight){
this.$el.toggleClass('highlight',!!highlight);
},
// alternative highlight color
altlight: function(altlight){
this.$el.toggleClass('altlight',!!altlight);
},
});

/* -------- The Product Screen -------- */

var ProductScreenWidget = ScreenWidget.extend({


template:'ProductScreenWidget',

start: function(){

var self = this;

this.actionpad = new ActionpadWidget(this,{});


this.actionpad.replace(this.$('.placeholder-ActionpadWidget'));

this.numpad = new NumpadWidget(this,{});


this.numpad.replace(this.$('.placeholder-NumpadWidget'));

this.order_widget = new OrderWidget(this,{


numpad_state: this.numpad.state,
});
this.order_widget.replace(this.$('.placeholder-OrderWidget'));

this.product_list_widget = new ProductListWidget(this,{


click_product_action: function(product){ self.click_product(product); },
product_list: this.pos.db.get_product_by_category(0)
});
this.product_list_widget.replace(this.$('.placeholder-ProductListWidget'));

this.product_categories_widget = new ProductCategoriesWidget(this,{


product_list_widget: this.product_list_widget,
});
this.product_categories_widget.replace(this.$('.placeholder-
ProductCategoriesWidget'));

this.action_buttons = {};
var classes = action_button_classes;
for (var i = 0; i < classes.length; i++) {
var classe = classes[i];
if ( !classe.condition || classe.condition.call(this) ) {
var widget = new classe.widget(this,{});
widget.appendTo(this.$('.control-buttons'));
this.action_buttons[classe.name] = widget;
}
}
if (_.size(this.action_buttons)) {
this.$('.control-buttons').removeClass('oe_hidden');
}
},

click_product: function(product) {
if(product.to_weight && this.pos.config.iface_electronic_scale){
this.gui.show_screen('scale',{product: product});
}else{
this.pos.get_order().add_product(product);
}
},

show: function(reset){
this._super();
if (reset) {
this.product_categories_widget.reset_category();
this.numpad.state.reset();
}
},

close: function(){
this._super();
if(this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard){
this.chrome.widget.keyboard.hide();
}
},
});
gui.define_screen({name:'products', widget: ProductScreenWidget});

/*--------------------------------------*\
| THE CLIENT LIST |
\*======================================*/

// The clientlist displays the list of customer,


// and allows the cashier to create, edit and assign
// customers.

var ClientListScreenWidget = ScreenWidget.extend({


template: 'ClientListScreenWidget',

init: function(parent, options){


this._super(parent, options);
this.partner_cache = new DomCache();
},

auto_back: true,

show: function(){
var self = this;
this._super();

this.renderElement();
this.details_visible = false;
this.old_client = this.pos.get_order().get_client();

this.$('.back').click(function(){
self.gui.back();
});

this.$('.next').click(function(){
self.save_changes();
self.gui.back(); // FIXME HUH ?
});

this.$('.new-customer').click(function(){
self.display_client_details('edit',{
'country_id': self.pos.company.country_id,
});
});

var partners = this.pos.db.get_partners_sorted(1000);


this.render_list(partners);

this.reload_partners();

if( this.old_client ){
this.display_client_details('show',this.old_client,0);
}

this.$('.client-list-contents').delegate('.client-
line','click',function(event){
self.line_select(event,$(this),parseInt($(this).data('id')));
});

var search_timeout = null;

if(this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard){


this.chrome.widget.keyboard.connect(this.$('.searchbox input'));
}

this.$('.searchbox input').on('keypress',function(event){
clearTimeout(search_timeout);

var query = this.value;

search_timeout = setTimeout(function(){
self.perform_search(query,event.which === 13);
},70);
});

this.$('.searchbox .search-clear').click(function(){
self.clear_search();
});
},
hide: function () {
this._super();
this.new_client = null;
},
barcode_client_action: function(code){
if (this.editing_client) {
this.$('.detail.barcode').val(code.code);
} else if (this.pos.db.get_partner_by_barcode(code.code)) {
var partner = this.pos.db.get_partner_by_barcode(code.code);
this.new_client = partner;
this.display_client_details('show', partner);
}
},
perform_search: function(query, associate_result){
var customers;
if(query){
customers = this.pos.db.search_partner(query);
this.display_client_details('hide');
if ( associate_result && customers.length === 1){
this.new_client = customers[0];
this.save_changes();
this.gui.back();
}
this.render_list(customers);
}else{
customers = this.pos.db.get_partners_sorted();
this.render_list(customers);
}
},
clear_search: function(){
var customers = this.pos.db.get_partners_sorted(1000);
this.render_list(customers);
this.$('.searchbox input')[0].value = '';
this.$('.searchbox input').focus();
},
render_list: function(partners){
var contents = this.$el[0].querySelector('.client-list-contents');
contents.innerHTML = "";
for(var i = 0, len = Math.min(partners.length,1000); i < len; i++){
var partner = partners[i];
var clientline = this.partner_cache.get_node(partner.id);
if(!clientline){
var clientline_html = QWeb.render('ClientLine',{widget: this,
partner:partners[i]});
var clientline = document.createElement('tbody');
clientline.innerHTML = clientline_html;
clientline = clientline.childNodes[1];
this.partner_cache.cache_node(partner.id,clientline);
}
if( partner === this.old_client ){
clientline.classList.add('highlight');
}else{
clientline.classList.remove('highlight');
}
contents.appendChild(clientline);
}
},
save_changes: function(){
var self = this;
var order = this.pos.get_order();
if( this.has_client_changed() ){
if ( this.new_client ) {
order.fiscal_position = _.find(this.pos.fiscal_positions, function
(fp) {
return fp.id === self.new_client.property_account_position_id[0];
});
} else {
order.fiscal_position = undefined;
}

order.set_client(this.new_client);
}
},
has_client_changed: function(){
if( this.old_client && this.new_client ){
return this.old_client.id !== this.new_client.id;
}else{
return !!this.old_client !== !!this.new_client;
}
},
toggle_save_button: function(){
var $button = this.$('.button.next');
if (this.editing_client) {
$button.addClass('oe_hidden');
return;
} else if( this.new_client ){
if( !this.old_client){
$button.text(_t('Set Customer'));
}else{
$button.text(_t('Change Customer'));
}
}else{
$button.text(_t('Deselect Customer'));
}
$button.toggleClass('oe_hidden',!this.has_client_changed());
},
line_select: function(event,$line,id){
var partner = this.pos.db.get_partner_by_id(id);
this.$('.client-list .lowlight').removeClass('lowlight');
if ( $line.hasClass('highlight') ){
$line.removeClass('highlight');
$line.addClass('lowlight');
this.display_client_details('hide',partner);
this.new_client = null;
this.toggle_save_button();
}else{
this.$('.client-list .highlight').removeClass('highlight');
$line.addClass('highlight');
var y = event.pageY - $line.parent().offset().top;
this.display_client_details('show',partner,y);
this.new_client = partner;
this.toggle_save_button();
}
},
partner_icon_url: function(id){
return '/web/image?model=res.partner&id='+id+'&field=image_small';
},

// ui handle for the 'edit selected customer' action


edit_client_details: function(partner) {
this.display_client_details('edit',partner);
},

// ui handle for the 'cancel customer edit changes' action


undo_client_details: function(partner) {
if (!partner.id) {
this.display_client_details('hide');
} else {
this.display_client_details('show',partner);
}
},

// what happens when we save the changes on the client edit form -> we fetch the
fields, sanitize them,
// send them to the backend for update, and call saved_client_details() when the
server tells us the
// save was successfull.
save_client_details: function(partner) {
var self = this;

var fields = {};


this.$('.client-details-contents .detail').each(function(idx,el){
fields[el.name] = el.value || false;
});

if (!fields.name) {
this.gui.show_popup('error',_t('A Customer Name Is Required'));
return;
}

if (this.uploaded_picture) {
fields.image = this.uploaded_picture;
}

fields.id = partner.id || false;


fields.country_id = fields.country_id || false;

new
Model('res.partner').call('create_from_ui',[fields]).then(function(partner_id){
self.saved_client_details(partner_id);
},function(err,event){
event.preventDefault();
self.gui.show_popup('error',{
'title': _t('Error: Could not Save Changes'),
'body': _t('Your Internet connection is probably down.'),
});
});
},

// what happens when we've just pushed modifications for a partner of id


partner_id
saved_client_details: function(partner_id){
var self = this;
this.reload_partners().then(function(){
var partner = self.pos.db.get_partner_by_id(partner_id);
if (partner) {
self.new_client = partner;
self.toggle_save_button();
self.display_client_details('show',partner);
} else {
// should never happen, because create_from_ui must return the id of
the partner it
// has created, and reload_partner() must have loaded the newly
created partner.
self.display_client_details('hide');
}
});
},

// resizes an image, keeping the aspect ratio intact,


// the resize is useful to avoid sending 12Mpixels jpegs
// over a wireless connection.
resize_image_to_dataurl: function(img, maxwidth, maxheight, callback){
img.onload = function(){
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
var ratio = 1;

if (img.width > maxwidth) {


ratio = maxwidth / img.width;
}
if (img.height * ratio > maxheight) {
ratio = maxheight / img.height;
}
var width = Math.floor(img.width * ratio);
var height = Math.floor(img.height * ratio);

canvas.width = width;
canvas.height = height;
ctx.drawImage(img,0,0,width,height);

var dataurl = canvas.toDataURL();


callback(dataurl);
};
},

// Loads and resizes a File that contains an image.


// callback gets a dataurl in case of success.
load_image_file: function(file, callback){
var self = this;
if (!file.type.match(/image.*/)) {
this.gui.show_popup('error',{
title: _t('Unsupported File Format'),
body: _t('Only web-compatible Image formats such as .png or .jpeg are
supported'),
});
return;
}
var reader = new FileReader();
reader.onload = function(event){
var dataurl = event.target.result;
var img = new Image();
img.src = dataurl;
self.resize_image_to_dataurl(img,800,600,callback);
};
reader.onerror = function(){
self.gui.show_popup('error',{
title :_t('Could Not Read Image'),
body :_t('The provided file could not be read due to an unknown
error'),
});
};
reader.readAsDataURL(file);
},

// This fetches partner changes on the server, and in case of changes,


// rerenders the affected views
reload_partners: function(){
var self = this;
return this.pos.load_new_partners().then(function(){
self.render_list(self.pos.db.get_partners_sorted(1000));

// update the currently assigned client if it has been changed in db.


var curr_client = self.pos.get_order().get_client();
if (curr_client) {

self.pos.get_order().set_client(self.pos.db.get_partner_by_id(curr_client.id));
}
});
},

// Shows,hides or edit the customer details box :


// visibility: 'show', 'hide' or 'edit'
// partner: the partner object to show or edit
// clickpos: the height of the click on the list (in pixel), used
// to maintain consistent scroll.
display_client_details: function(visibility,partner,clickpos){
var self = this;
var contents = this.$('.client-details-contents');
var parent = this.$('.client-list').parent();
var scroll = parent.scrollTop();
var height = contents.height();

contents.off('click','.button.edit');
contents.off('click','.button.save');
contents.off('click','.button.undo');
contents.on('click','.button.edit',function(){
self.edit_client_details(partner); });
contents.on('click','.button.save',function(){
self.save_client_details(partner); });
contents.on('click','.button.undo',function(){
self.undo_client_details(partner); });
this.editing_client = false;
this.uploaded_picture = null;

if(visibility === 'show'){


contents.empty();

contents.append($(QWeb.render('ClientDetails',{widget:this,partner:partner})));
var new_height = contents.height();

if(!this.details_visible){
// resize client list to take into account client details
parent.height('-=' + new_height);

if(clickpos < scroll + new_height + 20 ){


parent.scrollTop( clickpos - 20 );
}else{
parent.scrollTop(parent.scrollTop() + new_height);
}
}else{
parent.scrollTop(parent.scrollTop() - height + new_height);
}

this.details_visible = true;
this.toggle_save_button();
} else if (visibility === 'edit') {
this.editing_client = true;
contents.empty();

contents.append($(QWeb.render('ClientDetailsEdit',{widget:this,partner:partner})));
this.toggle_save_button();

// Browsers attempt to scroll invisible input elements


// into view (eg. when hidden behind keyboard). They don't
// seem to take into account that some elements are not
// scrollable.
contents.find('input').blur(function() {
setTimeout(function() {
self.$('.window').scrollTop(0);
}, 0);
});

contents.find('.image-uploader').on('change',function(event){
self.load_image_file(event.target.files[0],function(res){
if (res) {
contents.find('.client-picture img, .client-picture
.fa').remove();
contents.find('.client-picture').append("<img
src='"+res+"'>");
contents.find('.detail.picture').remove();
self.uploaded_picture = res;
}
});
});
} else if (visibility === 'hide') {
contents.empty();
parent.height('100%');
if( height > scroll ){
contents.css({height:height+'px'});
contents.animate({height:0},400,function(){
contents.css({height:''});
});
}else{
parent.scrollTop( parent.scrollTop() - height);
}
this.details_visible = false;
this.toggle_save_button();
}
},
close: function(){
this._super();
},
});
gui.define_screen({name:'clientlist', widget: ClientListScreenWidget});

/*--------------------------------------*\
| THE RECEIPT SCREEN |
\*======================================*/

// The receipt screen displays the order's


// receipt and allows it to be printed in a web browser.
// The receipt screen is not shown if the point of sale
// is set up to print with the proxy. Altough it could
// be useful to do so...

var ReceiptScreenWidget = ScreenWidget.extend({


template: 'ReceiptScreenWidget',
show: function(){
this._super();
var self = this;

this.render_change();
this.render_receipt();
this.handle_auto_print();
},
handle_auto_print: function() {
if (this.should_auto_print()) {
this.print();
if (this.should_close_immediately()){
this.click_next();
}
} else {
this.lock_screen(false);
}
},
should_auto_print: function() {
return this.pos.config.iface_print_auto && !this.pos.get_order()._printed;
},
should_close_immediately: function() {
return this.pos.config.iface_print_via_proxy &&
this.pos.config.iface_print_skip_screen;
},
lock_screen: function(locked) {
this._locked = locked;
if (locked) {
this.$('.next').removeClass('highlight');
} else {
this.$('.next').addClass('highlight');
}
},
print_web: function() {
window.print();
this.pos.get_order()._printed = true;
},
print_xml: function() {
var env = {
widget: this,
order: this.pos.get_order(),
receipt: this.pos.get_order().export_for_printing(),
paymentlines: this.pos.get_order().get_paymentlines()
};
var receipt = QWeb.render('XmlReceipt',env);

this.pos.proxy.print_receipt(receipt);
this.pos.get_order()._printed = true;
},
print: function() {
var self = this;

if (!this.pos.config.iface_print_via_proxy) { // browser (html) printing

// The problem is that in chrome the print() is asynchronous and doesn't


// execute until all rpc are finished. So it conflicts with the rpc used
// to send the orders to the backend, and the user is able to go to the
next
// screen before the printing dialog is opened. The problem is that what's
// printed is whatever is in the page when the dialog is opened and not
when it's called,
// and so you end up printing the product list instead of the receipt...
//
// Fixing this would need a re-architecturing
// of the code to postpone sending of orders after printing.
//
// But since the print dialog also blocks the other asynchronous calls,
the
// button enabling in the setTimeout() is blocked until the printing
dialog is
// closed. But the timeout has to be big enough or else it doesn't work
// 1 seconds is the same as the default timeout for sending orders and so
the dialog
// should have appeared before the timeout... so yeah that's not ultra
reliable.

this.lock_screen(true);

setTimeout(function(){
self.lock_screen(false);
}, 1000);

this.print_web();
} else { // proxy (xml) printing
this.print_xml();
this.lock_screen(false);
}
},
click_next: function() {
this.pos.get_order().finalize();
},
click_back: function() {
// Placeholder method for ReceiptScreen extensions that
// can go back ...
},
renderElement: function() {
var self = this;
this._super();
this.$('.next').click(function(){
if (!self._locked) {
self.click_next();
}
});
this.$('.back').click(function(){
if (!self._locked) {
self.click_back();
}
});
this.$('.button.print').click(function(){
if (!self._locked) {
self.print();
}
});
},
render_change: function() {
this.$('.change-
value').html(this.format_currency(this.pos.get_order().get_change()));
},
render_receipt: function() {
var order = this.pos.get_order();
this.$('.pos-receipt-container').html(QWeb.render('PosTicket',{
widget:this,
order: order,
receipt: order.export_for_printing(),
orderlines: order.get_orderlines(),
paymentlines: order.get_paymentlines(),
}));
},
});
gui.define_screen({name:'receipt', widget: ReceiptScreenWidget});

/*--------------------------------------*\
| THE PAYMENT SCREEN |
\*======================================*/

// The Payment Screen handles the payments, and


// it is unfortunately quite complicated.

var PaymentScreenWidget = ScreenWidget.extend({


template: 'PaymentScreenWidget',
back_screen: 'product',
init: function(parent, options) {
var self = this;
this._super(parent, options);

this.pos.bind('change:selectedOrder',function(){
this.renderElement();
this.watch_order_changes();
},this);
this.watch_order_changes();

this.inputbuffer = "";
this.firstinput = true;
this.decimal_point = _t.database.parameters.decimal_point;

// This is a keydown handler that prevents backspace from


// doing a back navigation. It also makes sure that keys that
// do not generate a keypress in Chrom{e,ium} (eg. delete,
// backspace, ...) get passed to the keypress handler.
this.keyboard_keydown_handler = function(event){
if (event.keyCode === 8 || event.keyCode === 46) { // Backspace and Delete
event.preventDefault();

// These do not generate keypress events in


// Chrom{e,ium}. Even if they did, we just called
// preventDefault which will cancel any keypress that
// would normally follow. So we call keyboard_handler
// explicitly with this keydown event.
self.keyboard_handler(event);
}
};

// This keyboard handler listens for keypress events. It is


// also called explicitly to handle some keydown events that
// do not generate keypress events.
this.keyboard_handler = function(event){
var key = '';

if (event.type === "keypress") {


if (event.keyCode === 13) { // Enter
self.validate_order();
} else if ( event.keyCode === 190 || // Dot
event.keyCode === 110 || // Decimal point (numpad)
event.keyCode === 188 || // Comma
event.keyCode === 46 ) { // Numpad dot
key = self.decimal_point;
} else if (event.keyCode >= 48 && event.keyCode <= 57) { // Numbers
key = '' + (event.keyCode - 48);
} else if (event.keyCode === 45) { // Minus
key = '-';
} else if (event.keyCode === 43) { // Plus
key = '+';
}
} else { // keyup/keydown
if (event.keyCode === 46) { // Delete
key = 'CLEAR';
} else if (event.keyCode === 8) { // Backspace
key = 'BACKSPACE';
}
}

self.payment_input(key);
event.preventDefault();
};

this.pos.bind('change:selectedClient', function() {
self.customer_changed();
}, this);
},
// resets the current input buffer
reset_input: function(){
var line = this.pos.get_order().selected_paymentline;
this.firstinput = true;
if (line) {
this.inputbuffer = this.format_currency_no_symbol(line.get_amount());
} else {
this.inputbuffer = "";
}
},
// handle both keyboard and numpad input. Accepts
// a string that represents the key pressed.
payment_input: function(input) {
var newbuf = this.gui.numpad_input(this.inputbuffer, input, {'firstinput':
this.firstinput});

this.firstinput = (newbuf.length === 0);

// popup block inputs to prevent sneak editing.


if (this.gui.has_popup()) {
return;
}

if (newbuf !== this.inputbuffer) {


this.inputbuffer = newbuf;
var order = this.pos.get_order();
if (order.selected_paymentline) {
var amount = this.inputbuffer;

if (this.inputbuffer !== "-") {


amount = formats.parse_value(this.inputbuffer, {type: "float"},
0.0);
}

order.selected_paymentline.set_amount(amount);
this.order_changes();
this.render_paymentlines();
this.$('.paymentline.selected
.edit').text(this.format_currency_no_symbol(amount));
}
}
},
click_numpad: function(button) {
var paymentlines = this.pos.get_order().get_paymentlines();
var open_paymentline = false;

for (var i = 0; i < paymentlines.length; i++) {


if (! paymentlines[i].paid) {
open_paymentline = true;
}
}

if (! open_paymentline) {
this.pos.get_order().add_paymentline( this.pos.cashregisters[0]);
this.render_paymentlines();
}

this.payment_input(button.data('action'));
},
render_numpad: function() {
var self = this;
var numpad = $(QWeb.render('PaymentScreen-Numpad', { widget:this }));
numpad.on('click','button',function(){
self.click_numpad($(this));
});
return numpad;
},
click_delete_paymentline: function(cid){
var lines = this.pos.get_order().get_paymentlines();
for ( var i = 0; i < lines.length; i++ ) {
if (lines[i].cid === cid) {
this.pos.get_order().remove_paymentline(lines[i]);
this.reset_input();
this.render_paymentlines();
return;
}
}
},
click_paymentline: function(cid){
var lines = this.pos.get_order().get_paymentlines();
for ( var i = 0; i < lines.length; i++ ) {
if (lines[i].cid === cid) {
this.pos.get_order().select_paymentline(lines[i]);
this.reset_input();
this.render_paymentlines();
return;
}
}
},
render_paymentlines: function() {
var self = this;
var order = this.pos.get_order();
if (!order) {
return;
}

var lines = order.get_paymentlines();


var due = order.get_due();
var extradue = 0;
if (due && lines.length && due !== order.get_due(lines[lines.length-1])) {
extradue = due;
}

this.$('.paymentlines-container').empty();
var lines = $(QWeb.render('PaymentScreen-Paymentlines', {
widget: this,
order: order,
paymentlines: lines,
extradue: extradue,
}));

lines.on('click','.delete-button',function(){
self.click_delete_paymentline($(this).data('cid'));
});

lines.on('click','.paymentline',function(){
self.click_paymentline($(this).data('cid'));
});

lines.appendTo(this.$('.paymentlines-container'));
},
click_paymentmethods: function(id) {
var cashregister = null;
for ( var i = 0; i < this.pos.cashregisters.length; i++ ) {
if ( this.pos.cashregisters[i].journal_id[0] === id ){
cashregister = this.pos.cashregisters[i];
break;
}
}
this.pos.get_order().add_paymentline( cashregister );
this.reset_input();
this.render_paymentlines();
},
render_paymentmethods: function() {
var self = this;
var methods = $(QWeb.render('PaymentScreen-Paymentmethods', { widget:this }));
methods.on('click','.paymentmethod',function(){
self.click_paymentmethods($(this).data('id'));
});
return methods;
},
click_invoice: function(){
var order = this.pos.get_order();
order.set_to_invoice(!order.is_to_invoice());
if (order.is_to_invoice()) {
this.$('.js_invoice').addClass('highlight');
} else {
this.$('.js_invoice').removeClass('highlight');
}
},
click_tip: function(){
var self = this;
var order = this.pos.get_order();
var tip = order.get_tip();
var change = order.get_change();
var value = tip;

if (tip === 0 && change > 0 ) {


value = change;
}

this.gui.show_popup('number',{
'title': tip ? _t('Change Tip') : _t('Add Tip'),
'value': self.format_currency_no_symbol(value),
'confirm': function(value) {
order.set_tip(formats.parse_value(value, {type: "float"}, 0));
self.order_changes();
self.render_paymentlines();
}
});
},
customer_changed: function() {
var client = this.pos.get_client();
this.$('.js_customer_name').text( client ? client.name : _t('Customer') );
},
click_set_customer: function(){
this.gui.show_screen('clientlist');
},
click_back: function(){
this.gui.show_screen('products');
},
renderElement: function() {
var self = this;
this._super();

var numpad = this.render_numpad();


numpad.appendTo(this.$('.payment-numpad'));

var methods = this.render_paymentmethods();


methods.appendTo(this.$('.paymentmethods-container'));

this.render_paymentlines();

this.$('.back').click(function(){
self.click_back();
});

this.$('.next').click(function(){
self.validate_order();
});

this.$('.js_set_customer').click(function(){
self.click_set_customer();
});

this.$('.js_tip').click(function(){
self.click_tip();
});
this.$('.js_invoice').click(function(){
self.click_invoice();
});

this.$('.js_cashdrawer').click(function(){
self.pos.proxy.open_cashbox();
});
},
show: function(){
this.pos.get_order().clean_empty_paymentlines();
this.reset_input();
this.render_paymentlines();
this.order_changes();
window.document.body.addEventListener('keypress',this.keyboard_handler);

window.document.body.addEventListener('keydown',this.keyboard_keydown_handler);
this._super();
},
hide: function(){
window.document.body.removeEventListener('keypress',this.keyboard_handler);

window.document.body.removeEventListener('keydown',this.keyboard_keydown_handler);
this._super();
},
// sets up listeners to watch for order changes
watch_order_changes: function() {
var self = this;
var order = this.pos.get_order();
if (!order) {
return;
}
if(this.old_order){
this.old_order.unbind(null,null,this);
}
order.bind('all',function(){
self.order_changes();
});
this.old_order = order;
},
// called when the order is changed, used to show if
// the order is paid or not
order_changes: function(){
var self = this;
var order = this.pos.get_order();
if (!order) {
return;
} else if (order.is_paid()) {
self.$('.next').addClass('highlight');
}else{
self.$('.next').removeClass('highlight');
}
},

order_is_valid: function(force_validation) {
var self = this;
var order = this.pos.get_order();

// FIXME: this check is there because the backend is unable to


// process empty orders. This is not the right place to fix it.
if (order.get_orderlines().length === 0) {
this.gui.show_popup('error',{
'title': _t('Empty Order'),
'body': _t('There must be at least one product in your order before
it can be validated'),
});
return false;
}

var plines = order.get_paymentlines();


for (var i = 0; i < plines.length; i++) {
if (plines[i].get_type() === 'bank' && plines[i].get_amount() < 0) {
this.gui.show_popup('error',{
'message': _t('Negative Bank Payment'),
'comment': _t('You cannot have a negative amount in a Bank
payment. Use a cash payment method to return money to the customer.'),
});
return false;
}
}

if (!order.is_paid() || this.invoicing) {
return false;
}

// The exact amount must be paid if there is no cash payment method defined.
if (Math.abs(order.get_total_with_tax() - order.get_total_paid()) > 0.00001) {
var cash = false;
for (var i = 0; i < this.pos.cashregisters.length; i++) {
cash = cash || (this.pos.cashregisters[i].journal.type === 'cash');
}
if (!cash) {
this.gui.show_popup('error',{
title: _t('Cannot return change without a cash payment method'),
body: _t('There is no cash payment method available in this point
of sale to handle the change.\n\n Please pay the exact amount or add a cash payment
method in the point of sale configuration'),
});
return false;
}
}

// if the change is too large, it's probably an input error, make the user
confirm.
if (!force_validation && order.get_total_with_tax() > 0 &&
(order.get_total_with_tax() * 1000 < order.get_total_paid())) {
this.gui.show_popup('confirm',{
title: _t('Please Confirm Large Amount'),
body: _t('Are you sure that the customer wants to pay') +
' ' +
this.format_currency(order.get_total_paid()) +
' ' +
_t('for an order of') +
' ' +
this.format_currency(order.get_total_with_tax()) +
' ' +
_t('? Clicking "Confirm" will validate the payment.'),
confirm: function() {
self.validate_order('confirm');
},
});
return false;
}

return true;
},

finalize_validation: function() {
var self = this;
var order = this.pos.get_order();

if (order.is_paid_with_cash() && this.pos.config.iface_cashdrawer) {


this.pos.proxy.open_cashbox();
}

order.initialize_validation_date();

if (order.is_to_invoice()) {
var invoiced = this.pos.push_and_invoice_order(order);
this.invoicing = true;

invoiced.fail(function(error){
self.invoicing = false;
if (error.message === 'Missing Customer') {
self.gui.show_popup('confirm',{
'title': _t('Please select the Customer'),
'body': _t('You need to select the customer before you can
invoice an order.'),
confirm: function(){
self.gui.show_screen('clientlist');
},
});
} else if (error.code < 0) { // XmlHttpRequest Errors
self.gui.show_popup('error',{
'title': _t('The order could not be sent'),
'body': _t('Check your internet connection and try again.'),
});
} else if (error.code === 200) { // OpenERP Server Errors
self.gui.show_popup('error-traceback',{
'title': error.data.message || _t("Server Error"),
'body': error.data.debug || _t('The server encountered an
error while receiving your order.'),
});
} else { // ???
self.gui.show_popup('error',{
'title': _t("Unknown Error"),
'body': _t("The order could not be sent to the server due to
an unknown error"),
});
}
});

invoiced.done(function(){
self.invoicing = false;
self.gui.show_screen('receipt');
});
} else {
this.pos.push_order(order);
this.gui.show_screen('receipt');
}

},

// Check if the order is paid, then sends it to the backend,


// and complete the sale process
validate_order: function(force_validation) {
if (this.order_is_valid(force_validation)) {
this.finalize_validation();
}
},
});
gui.define_screen({name:'payment', widget: PaymentScreenWidget});

var set_fiscal_position_button = ActionButtonWidget.extend({


template: 'SetFiscalPositionButton',
init: function (parent, options) {
this._super(parent, options);

this.pos.get('orders').bind('add remove change', function () {


this.renderElement();
}, this);

this.pos.bind('change:selectedOrder', function () {
this.renderElement();
}, this);
},
button_click: function () {
var self = this;

var no_fiscal_position = [{
label: _t("None"),
}];
var fiscal_positions = _.map(self.pos.fiscal_positions, function
(fiscal_position) {
return {
label: fiscal_position.name,
item: fiscal_position
};
});

var selection_list = no_fiscal_position.concat(fiscal_positions);


self.gui.show_popup('selection',{
title: _t('Select tax'),
list: selection_list,
confirm: function (fiscal_position) {
var order = self.pos.get_order();
order.fiscal_position = fiscal_position;
order.trigger('change');
}
});
},
get_current_fiscal_position_name: function () {
var name = _t('Tax');
var order = this.pos.get_order();

if (order) {
var fiscal_position = order.fiscal_position;

if (fiscal_position) {
name = fiscal_position.display_name;
}
}

return name;
}
});

define_action_button({
'name': 'set_fiscal_position',
'widget': set_fiscal_position_button,
'condition': function(){
return this.pos.fiscal_positions.length > 0;
},
});

return {
ReceiptScreenWidget: ReceiptScreenWidget,
ActionButtonWidget: ActionButtonWidget,
define_action_button: define_action_button,
ScreenWidget: ScreenWidget,
PaymentScreenWidget: PaymentScreenWidget,
OrderWidget: OrderWidget,
NumpadWidget: NumpadWidget,
ProductScreenWidget: ProductScreenWidget,
ProductListWidget: ProductListWidget,
ClientListScreenWidget: ClientListScreenWidget,
ActionpadWidget: ActionpadWidget,
DomCache: DomCache,
ProductCategoriesWidget: ProductCategoriesWidget,
ScaleScreenWidget: ScaleScreenWidget,
set_fiscal_position_button: set_fiscal_position_button,
};

});
POS GUI JS
odoo.define('point_of_sale.gui', function (require) {
"use strict";
// this file contains the Gui, which is the pos 'controller'.
// It contains high level methods to manipulate the interface
// such as changing between screens, creating popups, etc.
//
// it is available to all pos objects trough the '.gui' field.

var core = require('web.core');


var Model = require('web.DataModel');
var formats = require('web.formats');
var session = require('web.session');

var _t = core._t;

var Gui = core.Class.extend({


screen_classes: [],
popup_classes: [],
init: function(options){
var self = this;
this.pos = options.pos;
this.chrome = options.chrome;
this.screen_instances = {};
this.popup_instances = {};
this.default_screen = null;
this.startup_screen = null;
this.current_popup = null;
this.current_screen = null;

this.chrome.ready.then(function(){
self.close_other_tabs();
var order = self.pos.get_order();
if (order) {
self.show_saved_screen(order);
} else {
self.show_screen(self.startup_screen);
}
self.pos.bind('change:selectedOrder', function(){
self.show_saved_screen(self.pos.get_order());
});
});
},

/* ---- Gui: SCREEN MANIPULATION ---- */

// register a screen widget to the gui,


// it must have been inserted into the dom.
add_screen: function(name, screen){
screen.hide();
this.screen_instances[name] = screen;
},

// sets the screen that will be displayed


// for new orders
set_default_screen: function(name){
this.default_screen = name;
},

// sets the screen that will be displayed


// when no orders are present
set_startup_screen: function(name) {
this.startup_screen = name;
},

// display the screen saved in an order,


// called when the user changes the current order
// no screen saved ? -> display default_screen
// no order ? -> display startup_screen
show_saved_screen: function(order,options) {
options = options || {};
this.close_popup();
if (order) {
this.show_screen(order.get_screen_data('screen') ||
options.default_screen ||
this.default_screen,
null,'refresh');
} else {
this.show_screen(this.startup_screen);
}
},

// display a screen.
// If there is an order, the screen will be saved in the order
// - params: used to load a screen with parameters, for
// example loading a 'product_details' screen for a specific product.
// - refresh: if you want the screen to cycle trough show / hide even
// if you are already on the same screen.
show_screen: function(screen_name,params,refresh,skip_close_popup) {
var screen = this.screen_instances[screen_name];
if (!screen) {
console.error("ERROR: show_screen("+screen_name+") : screen not found");
}
if (!skip_close_popup){
this.close_popup();
}
var order = this.pos.get_order();
if (order) {
var old_screen_name = order.get_screen_data('screen');

order.set_screen_data('screen',screen_name);

if(params){
order.set_screen_data('params',params);
}

if( screen_name !== old_screen_name ){


order.set_screen_data('previous-screen',old_screen_name);
}
}

if (refresh || screen !== this.current_screen) {


if (this.current_screen) {
this.current_screen.close();
this.current_screen.hide();
}
this.current_screen = screen;
this.current_screen.show(refresh);
}
},

// returns the current screen.


get_current_screen: function() {
return this.pos.get_order() ? ( this.pos.get_order().get_screen_data('screen')
|| this.default_screen ) : this.startup_screen;
},

// goes to the previous screen (as specified in the order). The history only
// goes 1 deep ...
back: function() {
var previous = this.pos.get_order().get_screen_data('previous-screen');
if (previous) {
this.show_screen(previous);
}
},

// returns the parameter specified when this screen was displayed


get_current_screen_param: function(param) {
if (this.pos.get_order()) {
var params = this.pos.get_order().get_screen_data('params');
return params ? params[param] : undefined;
} else {
return undefined;
}
},

/* ---- Gui: POPUP MANIPULATION ---- */

// registers a new popup in the GUI.


// the popup must have been previously inserted
// into the dom.
add_popup: function(name, popup) {
popup.hide();
this.popup_instances[name] = popup;
},

// displays a popup. Popup do not stack,


// are not remembered by the order, and are
// closed by screen changes or new popups.
show_popup: function(name,options) {
if (this.current_popup) {
this.close_popup();
}
this.current_popup = this.popup_instances[name];
return this.current_popup.show(options);
},

// close the current popup.


close_popup: function() {
if (this.current_popup) {
this.current_popup.close();
this.current_popup.hide();
this.current_popup = null;
}
},

// is there an active popup ?


has_popup: function() {
return !!this.current_popup;
},

/* ---- Gui: INTER TAB COMM ---- */

// This sets up automatic pos exit when open in


// another tab.
close_other_tabs: function() {
var self = this;
// avoid closing itself
var now = Date.now();

localStorage['message'] = '';
localStorage['message'] = JSON.stringify({
'message':'close_tabs',
'session': this.pos.pos_session.id,
'window_uid': now,
});

// storage events are (most of the time) triggered only when the
// localstorage is updated in a different tab.
// some browsers (e.g. IE) does trigger an event in the same tab
// This may be a browser bug or a different interpretation of the HTML spec
// cf https://connect.microsoft.com/IE/feedback/details/774798/localstorage-
event-fired-in-source-window
// Use window_uid parameter to exclude the current window
window.addEventListener("storage", function(event) {
var msg = event.data;

if ( event.key === 'message' && event.newValue) {

var msg = JSON.parse(event.newValue);


if ( msg.message === 'close_tabs' &&
msg.session == self.pos.pos_session.id &&
msg.window_uid != now) {

console.info('POS / Session opened in another window. EXITING


POS')
self._close();
}
}

}, false);
},

/* ---- Gui: ACCESS CONTROL ---- */

// A Generic UI that allow to select a user from a list.


// It returns a deferred that resolves with the selected user
// upon success. Several options are available :
// - security: passwords will be asked
// - only_managers: restricts the list to managers
// - current_user: password will not be asked if this
// user is selected.
// - title: The title of the user selection list.
select_user: function(options){
options = options || {};
var self = this;
var def = new $.Deferred();

var list = [];


for (var i = 0; i < this.pos.users.length; i++) {
var user = this.pos.users[i];
if (!options.only_managers || user.role === 'manager') {
list.push({
'label': user.name,
'item': user,
});
}
}
this.show_popup('selection',{
'title': options.title || _t('Select User'),
list: list,
confirm: function(user){ def.resolve(user); },
cancel: function(){ def.reject(); },
});

return def.then(function(user){
if (options.security && user !== options.current_user &&
user.pos_security_pin) {
return self.ask_password(user.pos_security_pin).then(function(){
return user;
});
} else {
return user;
}
});
},

// Ask for a password, and checks if it this


// the same as specified by the function call.
// returns a deferred that resolves on success,
// fails on failure.
ask_password: function(password) {
var self = this;
var ret = new $.Deferred();
if (password) {
this.show_popup('password',{
'title': _t('Password ?'),
confirm: function(pw) {
if (pw !== password) {
self.show_popup('error',_t('Incorrect Password'));
ret.reject();
} else {
ret.resolve();
}
},
});
} else {
ret.resolve();
}
return ret;
},

// checks if the current user (or the user provided) has manager
// access rights. If not, a popup is shown allowing the user to
// temporarily login as an administrator.
// This method returns a deferred, that succeeds with the
// manager user when the login is successfull.
sudo: function(user){
user = user || this.pos.get_cashier();

if (user.role === 'manager') {


return new $.Deferred().resolve(user);
} else {
return this.select_user({
security: true,
only_managers: true,
title: _t('Login as a Manager'),
});
}
},
/* ---- Gui: CLOSING THE POINT OF SALE ---- */

close: function() {
var self = this;
var pending = this.pos.db.get_orders().length;

if (!pending) {
this._close();
} else {
this.pos.push_order().always(function() {
var pending = self.pos.db.get_orders().length;
if (!pending) {
self._close();
} else {
var reason = self.pos.get('failed') ?
'configuration errors' :
'internet connection issues';

self.show_popup('confirm', {
'title': _t('Offline Orders'),
'body': _t(['Some orders could not be submitted to',
'the server due to ' + reason + '.',
'You can exit the Point of Sale, but do',
'not close the session before the issue',
'has been resolved.'].join(' ')),
'confirm': function() {
self._close();
},
});
}
});
}
},

_close: function() {
var self = this;
this.chrome.loading_show();
this.chrome.loading_message(_t('Closing ...'));

this.pos.push_order().then(function(){
var url = "/web#action=point_of_sale.action_client_pos_menu";
window.location = session.debug ? $.param.querystring(url, {debug:
session.debug}) : url;
});
},

/* ---- Gui: SOUND ---- */

play_sound: function(sound) {
var src = '';
if (sound === 'error') {
src = "/point_of_sale/static/src/sounds/error.wav";
} else if (sound === 'bell') {
src = "/point_of_sale/static/src/sounds/bell.wav";
} else {
console.error('Unknown sound: ',sound);
return;
}
$('body').append('<audio src="'+src+'" autoplay="true"></audio>');
},

/* ---- Gui: FILE I/O ---- */


// This will make the browser download 'contents' as a
// file named 'name'
// if 'contents' is not a string, it is converted into
// a JSON representation of the contents.

// TODO: remove me in master: deprecated in favor of prepare_download_link


// this method is kept for backward compatibility but is likely not going
// to work as many browsers do to not accept fake click events on links
download_file: function(contents, name) {
href_params = this.prepare_file_blob(contents,name);
var evt = document.createEvent("HTMLEvents");
evt.initEvent("click");

$("<a>",href_params).get(0).dispatchEvent(evt);

},

prepare_download_link: function(contents, filename, src, target) {


var href_params = this.prepare_file_blob(contents, filename);

$(target).parent().attr(href_params);
$(src).addClass('oe_hidden');
$(target).removeClass('oe_hidden');

// hide again after click


$(target).click(function() {
$(src).removeClass('oe_hidden');
$(this).addClass('oe_hidden');
});
},

prepare_file_blob: function(contents, name) {


var URL = window.URL || window.webkitURL;

if (typeof contents !== 'string') {


contents = JSON.stringify(contents,null,2);
}

var blob = new Blob([contents]);

return {download: name || 'document.txt',


href: URL.createObjectURL(blob),}
},

/* ---- Gui: EMAILS ---- */

// This will launch the user's email software


// with a new email with the address, subject and body
// prefilled.

send_email: function(address, subject, body) {


window.open("mailto:" + address +
"?subject=" + (subject ? window.encodeURIComponent(subject)
: '') +
"&body=" + (body ? window.encodeURIComponent(body) : ''));
},

/* ---- Gui: KEYBOARD INPUT ---- */

// This is a helper to handle numpad keyboard input.


// - buffer: an empty or number string
// - input: '[0-9],'+','-','.','CLEAR','BACKSPACE'
// - options: 'firstinput' -> will clear buffer if
// input is '[0-9]' or '.'
// returns the new buffer containing the modifications
// (the original is not touched)
numpad_input: function(buffer, input, options) {
var newbuf = buffer.slice(0);
options = options || {};
var newbuf_float = formats.parse_value(newbuf, {type: "float"}, 0);
var decimal_point = _t.database.parameters.decimal_point;

if (input === decimal_point) {


if (options.firstinput) {
newbuf = "0.";
}else if (!newbuf.length || newbuf === '-') {
newbuf += "0.";
} else if (newbuf.indexOf(decimal_point) < 0){
newbuf = newbuf + decimal_point;
}
} else if (input === 'CLEAR') {
newbuf = "";
} else if (input === 'BACKSPACE') {
newbuf = newbuf.substring(0,newbuf.length - 1);
} else if (input === '+') {
if ( newbuf[0] === '-' ) {
newbuf = newbuf.substring(1,newbuf.length);
}
} else if (input === '-') {
if ( newbuf[0] === '-' ) {
newbuf = newbuf.substring(1,newbuf.length);
} else {
newbuf = '-' + newbuf;
}
} else if (input[0] === '+' && !isNaN(parseFloat(input))) {
newbuf = this.chrome.format_currency_no_symbol(newbuf_float +
parseFloat(input));
} else if (!isNaN(parseInt(input))) {
if (options.firstinput) {
newbuf = '' + input;
} else {
newbuf += input;
}
}

// End of input buffer at 12 characters.


if (newbuf.length > buffer.length && newbuf.length > 12) {
this.play_sound('bell');
return buffer.slice(0);
}

return newbuf;
},
});

var define_screen = function (classe) {


Gui.prototype.screen_classes.push(classe);
};

var define_popup = function (classe) {


Gui.prototype.popup_classes.push(classe);
};

return {
Gui: Gui,
define_screen: define_screen,
define_popup: define_popup,
};

});
HAIRSTYLIST JS
odoo.define('pos_snips_updates.hair_stylist', function (require) {
"use strict";
var Class = require('web.Class');
var Model = require('web.Model');
var session = require('web.session');
var core = require('web.core');
var screens = require('point_of_sale.screens');
var gui = require('point_of_sale.gui');
var pos_model = require('point_of_sale.models');
var utils = require('web.utils');
var _t = core._t;

var BarcodeParser = require('barcodes.BarcodeParser');


var PopupWidget = require('point_of_sale.popups');
var ScreenWidget = screens.ScreenWidget;
var PaymentScreenWidget = screens.PaymentScreenWidget;
var round_pr = utils.round_precision;

var models = require('point_of_sale.models');


var QWeb = core.qweb;

models.load_models({
model: 'hr.employee',
fields: ['name', 'id',],
loaded: function (self, employees) {
self.employees = employees;
self.employees_by_id = {};
for (var i = 0; i < employees.length; i++) {
employees[i].tables = [];
self.employees_by_id[employees[i].id] = employees[i];
}

// Make sure they display in the correct order


self.employees = self.employees.sort(function (a, b) {
return a.sequence - b.sequence;
});

// Ignore floorplan features if no floor specified.


// self.config.iface_floorplan = !!self.employees.length;
},
});

var _super_orderline = models.Orderline.prototype;

models.Orderline = models.Orderline.extend({
initialize: function (attr, options) {
_super_orderline.initialize.call(this, attr, options);
// this.hair_stylist_id = this.hair_stylist_id || false;
// this.hair_stylist_name = this.hair_stylist_name || "";
if (!this.hair_stylist_id) {
this.hair_stylist_id = this.pos.hair_stylist_id;
}
if (!this.hair_stylist_name) {
this.hair_stylist_name = this.pos.hair_stylist_name;
}

},
set_hair_stylist_id: function (hair_stylist_id) {
this.hair_stylist_id = hair_stylist_id;
this.trigger('change', this);
},
get_hair_stylist_id: function () {
return this.hair_stylist_id;
},
set_hair_stylist_name: function (hair_stylist_name) {
this.hair_stylist_name = hair_stylist_name;
this.trigger('change', this);
},
get_hair_stylist_name: function () {
return this.hair_stylist_name;
},

clone: function () {
var orderline = _super_orderline.clone.call(this);
orderline.hair_stylist_id = this.hair_stylist_id;
orderline.hair_stylist_name = this.hair_stylist_name;
return orderline;
},
export_as_JSON: function () {
var json = _super_orderline.export_as_JSON.call(this);
json.hair_stylist_id = this.hair_stylist_id;
json.hair_stylist_name = this.hair_stylist_name;
return json;
},
init_from_JSON: function (json) {
_super_orderline.init_from_JSON.apply(this, arguments);
this.hair_stylist_id = json.hair_stylist_id;
this.hair_stylist_name = json.hair_stylist_name;
},
});

var OrderlineHairStylistButton = screens.ActionButtonWidget.extend({


template: 'OrderlineHairStylistButton',
button_click: function () {
var line = this.pos.get_order().get_selected_orderline();
if (line) {
var list = [];

console.log("OrderlineHairStylistButton");
var hair_stylists=this.pos.employees;
var hair_stylists_length=hair_stylists.length;

for (var i = 0; i < hair_stylists_length; i++) {


var hair_stylist = hair_stylists[i];

list.push({
'label': hair_stylist.name,
'item': hair_stylist,
});

}
//
//
var the_seleted=line.get_hair_stylist_name();
this.gui.show_popup('selection',{
'title':_t('Select Hair Stylist'),
list: list,
confirm: function (item) {
console.log("Item");
console.log(item);
line.set_hair_stylist_id(item.id);
line.set_hair_stylist_name(item.name);
},
cancel: function () { },
});

}
},
});

screens.define_action_button({
'name': 'orderline_note',
'widget': OrderlineHairStylistButton,

});

});
INTERNAL REFERENCE JS
odoo.define('pos_snips_updates.internal_reference', function (require) {
"use strict";

var models = require('point_of_sale.models');


var screens = require('point_of_sale.screens');
var core = require('web.core');
var utils = require('web.utils');
var QWeb = core.qweb;
var _t = core._t;

// New orders are now associated with the current table, if any.
var _super_order = models.Order.prototype;
models.Order = models.Order.extend({
initialize: function () {
console.log("internal_reference start")
_super_order.initialize.apply(this, arguments);
if (!this.internal_reference) {
this.internal_reference = this.pos.internal_reference;
}

this.save_to_db();
},
export_as_JSON: function () {
var json = _super_order.export_as_JSON.apply(this, arguments);
json.internal_reference = this.internal_reference;
return json;
},
init_from_JSON: function (json) {
_super_order.init_from_JSON.apply(this, arguments);
this.internal_reference = json.internal_reference || '';
},
export_for_printing: function () {
var json = _super_order.export_for_printing.apply(this, arguments);
json.internal_reference = this.get_internal_reference();
return json;
},
get_internal_reference: function () {
return this.internal_reference;
},
set_internal_reference: function (internal_reference) {
this.internal_reference = internal_reference;
this.trigger('change');
},
});

var InternalReferenceButton = screens.ActionButtonWidget.extend({


template: 'InternalReferenceButton',
internal_reference: function () {
if (this.pos.get_order()) {
return this.pos.get_order().internal_reference;
} else {
return '';
}
},
button_click: function () {
var self = this;
this.gui.show_popup('textinput', {
'title': _t('Internal Reference ?'),
'value': this.pos.get_order().get_internal_reference(),
'confirm': function (value) {
self.pos.get_order().set_internal_reference(value);
},
});
},
});

var PrintSessionButton = screens.ActionButtonWidget.extend({


template: 'PrintSessionButton',

button_click: function () {
var self = this;

console.log(" ..... Action TO Report PrintSession ...... ");

console.log(" from order session_id=" + this.pos.get_order().session_id);


console.log(" from cookie session_id=" + utils.get_cookie("session_id"));

var session_id = this.pos.get_order().session_id;

if (session_id) {

} else {
var session_id = utils.get_cookie("session_id");
}
console.log("session_id=" + session_id);

if (session_id) {

var additional_context = {active_ids: [session_id]}


self.do_action('pos_snips_updates.action_pos_snips_updates_report', {
additional_context: {active_ids: [session_id],}
});

//
self.do_action('pos_customer_history.action_report_customer_history', {
// additional_context: { active_ids: [self.so_id.id], } });
}

},
});

screens.OrderWidget.include({
update_summary: function () {
this._super();
if (this.getParent().action_buttons &&
this.getParent().action_buttons.internal_reference) {
this.getParent().action_buttons.internal_reference.renderElement();
}
},
});

screens.define_action_button({
'name': 'internal_reference',
'widget': InternalReferenceButton,

});

screens.define_action_button({
'name': 'PrintSessionButton',
'widget': PrintSessionButton,

});

});
POS CUSTOMER HISTORY JS
odoo.define('pos_customer_history.customer_history', function (require) {
"use strict";

var Class = require('web.Class');


var Model = require('web.Model');
var session = require('web.session');
var core = require('web.core');
var screens = require('point_of_sale.screens');
var gui = require('point_of_sale.gui');
var pos_model = require('point_of_sale.models');
var utils = require('web.utils');
var _t = core._t;

var BarcodeParser = require('barcodes.BarcodeParser');


var PopupWidget = require('point_of_sale.popups');
var ScreenWidget = screens.ScreenWidget;
var PaymentScreenWidget = screens.PaymentScreenWidget;
var round_pr = utils.round_precision;

var models = require('point_of_sale.models');


var QWeb = core.qweb;

var _super_orderline = models.Orderline.prototype;

//get customer number of orders and total of them


screens.ClientListScreenWidget.include({
initialize: function () {
console.log("init ClientListScreenWidget");
this.count_orders = this.count_orders ? this.get_count_orders() : 0;
this.total_orders = this.total_orders ? this.get_total_orders() : 0;
this.average = this.average ? this.get_average() : 0;
this.last_visit = this.last_visit ? this.get_last_visit() : '';

},
show: function () {
var self = this;
this._super();
this.$('.customer-history').click(function () {
// alert("customer");
console.log("Action
pos_customer_history.action_report_customer_history");

if (self.new_client) {
console.log("Client id=" + self.new_client.id)

var additional_context = { active_ids: [self.new_client.id] }

self.do_action('pos_customer_history.action_report_customer_history', {
additional_context: { active_ids: [self.new_client.id], }
});
}
});

},
get_count_orders: function () {
return this.count_orders;
},
set_count_orders: function (count_orders) {
this.count_orders = count_orders;
this.trigger('change');
},
get_last_visit: function () {
return this.last_visit;
},
set_last_visit: function (last_visit) {
this.last_visit = last_visit;
this.trigger('change');
},
get_average: function () {
return this.average;
},
set_average: function (average) {
this.average = average;
this.trigger('change');
},
get_total_orders: function () {
return this.total_orders;
},
set_total_orders: function (total_orders) {
this.total_orders = total_orders;
this.trigger('change');
},
display_client_details: function (visibility, partner, clickpos) {
var self = this;
var contents = this.$('.client-details-contents');
var parent = this.$('.client-list').parent();
var scroll = parent.scrollTop();
var height = contents.height();

contents.off('click', '.button.edit');
contents.off('click', '.button.save');
contents.off('click', '.button.undo');
contents.on('click', '.button.edit', function () {
self.edit_client_details(partner);
});
contents.on('click', '.button.save', function () {
self.save_client_details(partner);
});
contents.on('click', '.button.undo', function () {
self.undo_client_details(partner);
});
this.editing_client = false;
this.uploaded_picture = null;

if (visibility === 'show') {


contents.empty();

var posOrderModel = new Model('pos.order');


var temp=this;
console.log(temp);
console.log(partner);

posOrderModel.call('get_partner_history',[partner.id]).then(function(history_lst) {
contents.empty();
temp.set_last_visit(history_lst[3]);
temp.set_average(history_lst[2]);
temp.set_count_orders(history_lst[1]);
temp.set_total_orders(history_lst[0]);

contents.append($(QWeb.render('ClientDetails',{widget:temp,partner:partner})));

console.log(temp);
});

contents.append($(QWeb.render('ClientDetails', {widget: this, partner:


partner})));

var new_height = contents.height();

if (!this.details_visible) {
// resize client list to take into account client details
parent.height('-=' + new_height);

if (clickpos < scroll + new_height + 20) {


parent.scrollTop(clickpos - 20);
} else {
parent.scrollTop(parent.scrollTop() + new_height);
}
} else {
parent.scrollTop(parent.scrollTop() - height + new_height);
}

this.details_visible = true;
this.toggle_save_button();
} else if (visibility === 'edit') {
this.editing_client = true;
contents.empty();
contents.append($(QWeb.render('ClientDetailsEdit', {widget: this,
partner: partner})));
this.toggle_save_button();

// Browsers attempt to scroll invisible input elements


// into view (eg. when hidden behind keyboard). They don't
// seem to take into account that some elements are not
// scrollable.
contents.find('input').blur(function () {
setTimeout(function () {
self.$('.window').scrollTop(0);
}, 0);
});

contents.find('.image-uploader').on('change', function (event) {


self.load_image_file(event.target.files[0], function (res) {
if (res) {
contents.find('.client-picture img, .client-picture
.fa').remove();
contents.find('.client-picture').append("<img src='" + res
+ "'>");
contents.find('.detail.picture').remove();
self.uploaded_picture = res;
}
});
});
} else if (visibility === 'hide') {
contents.empty();
parent.height('100%');
if (height > scroll) {
contents.css({height: height + 'px'});
contents.animate({height: 0}, 400, function () {
contents.css({height: ''});
});
} else {
parent.scrollTop(parent.scrollTop() - height);
}
this.details_visible = false;
this.toggle_save_button();
}
},

});

});

Вам также может понравиться