1 function openerp_pos_models(instance, module){ //module is instance.point_of_sale
2 var QWeb = instance.web.qweb;
5 // The PosModel contains the Point Of Sale's representation of the backend.
6 // Since the PoS must work in standalone ( Without connection to the server )
7 // it must contains a representation of the server's PoS backend.
8 // (taxes, product list, configuration options, etc.) this representation
9 // is fetched and stored by the PosModel at the initialisation.
10 // this is done asynchronously, a ready deferred alows the GUI to wait interactively
11 // for the loading to be completed
12 // There is a single instance of the PosModel for each Front-End instance, it is usually called
13 // 'pos' and is available to all widgets extending PosWidget.
15 module.PosModel = Backbone.Model.extend({
16 initialize: function(session, attributes) {
17 Backbone.Model.prototype.initialize.call(this, attributes);
19 this.session = session;
20 this.ready = $.Deferred(); // used to notify the GUI that the PosModel has loaded all resources
21 this.flush_mutex = new $.Mutex(); // used to make sure the orders are sent to the server once at time
23 this.barcode_reader = new module.BarcodeReader({'pos': this}); // used to read barcodes
24 this.proxy = new module.ProxyDevice(); // used to communicate to the hardware devices via a local proxy
25 this.db = new module.PosLS(); // a database used to store the products and categories
26 this.db.clear('products','categories');
27 this.debug = jQuery.deparam(jQuery.param.querystring()).debug !== undefined; //debug mode
30 // default attributes values. If null, it will be loaded below.
32 'nbr_pending_operations': 0,
34 'currency': {symbol: '$', position: 'after'},
37 'user': null, // the user that loaded the pos
38 'user_list': null, // list of all users
39 'partner_list': null, // list of all partners with an ean
40 'cashier': null, // the logged cashier, if different from user
42 'orders': new module.OrderCollection(),
43 //this is the product list as seen by the product list widgets, it will change based on the category filters
44 'products': new module.ProductCollection(),
45 'cashRegisters': null,
47 'bank_statements': null,
54 'selectedOrder': null,
57 this.get('orders').bind('remove', function(){ self.on_removed_order(); });
59 // We fetch the backend data on the server asynchronously. this is done only when the pos user interface is launched,
60 // Any change on this data made on the server is thus not reflected on the point of sale until it is relaunched.
61 // when all the data has loaded, we compute some stuff, and declare the Pos ready to be used.
62 $.when(this.load_server_data())
64 //self.log_loaded_data(); //Uncomment if you want to log the data to the console for easier debugging
67 //we failed to load some backend data, or the backend was badly configured.
68 //the error messages will be displayed in PosWidget
73 // helper function to load data from the server
74 fetch: function(model, fields, domain, ctx){
75 return new instance.web.Model(model).query(fields).filter(domain).context(ctx).all()
77 // loads all the needed data on the sever. returns a deferred indicating when all the data has loaded.
78 load_server_data: function(){
81 var loaded = self.fetch('res.users',['name','company_id'],[['id','=',this.session.uid]])
82 .then(function(users){
83 self.set('user',users[0]);
85 return self.fetch('res.company',
96 [['id','=',users[0].company_id[0]]]);
97 }).then(function(companies){
98 self.set('company',companies[0]);
100 return self.fetch('res.partner',['contact_address'],[['id','=',companies[0].partner_id[0]]]);
101 }).then(function(company_partners){
102 self.get('company').contact_address = company_partners[0].contact_address;
104 return self.fetch('res.currency',['symbol','position'],[['id','=',self.get('company').currency_id[0]]]);
105 }).then(function(currencies){
106 self.set('currency',currencies[0]);
108 return self.fetch('product.uom', null, null);
109 }).then(function(units){
110 self.set('units',units);
111 var units_by_id = {};
112 for(var i = 0, len = units.length; i < len; i++){
113 units_by_id[units[i].id] = units[i];
115 self.set('units_by_id',units_by_id);
117 return self.fetch('product.packaging', null, null);
118 }).then(function(packagings){
119 self.set('product.packaging',packagings);
121 return self.fetch('res.users', ['name','ean13'], [['ean13', '!=', false]]);
122 }).then(function(users){
123 self.set('user_list',users);
125 return self.fetch('res.partner', ['name','ean13'], [['ean13', '!=', false]]);
126 }).then(function(partners){
127 self.set('partner_list',partners);
129 return self.fetch('account.tax', ['amount', 'price_include', 'type']);
130 }).then(function(taxes){
131 self.set('taxes', taxes);
135 ['id', 'journal_ids','name','user_id','config_id','start_at','stop_at'],
136 [['state', '=', 'opened'], ['user_id', '=', self.session.uid]]
138 }).then(function(sessions){
139 self.set('pos_session', sessions[0]);
143 ['name','journal_ids','shop_id','journal_id',
144 'iface_self_checkout', 'iface_led', 'iface_cashdrawer',
145 'iface_payment_terminal', 'iface_electronic_scale', 'iface_barscan', 'iface_vkeyboard',
146 'iface_print_via_proxy','iface_cashdrawer','state','sequence_id','session_ids'],
147 [['id','=', self.get('pos_session').config_id[0]]]
149 }).then(function(configs){
150 var pos_config = configs[0];
151 self.set('pos_config', pos_config);
152 self.iface_electronic_scale = !!pos_config.iface_electronic_scale;
153 self.iface_print_via_proxy = !!pos_config.iface_print_via_proxy;
154 self.iface_vkeyboard = !!pos_config.iface_vkeyboard;
155 self.iface_self_checkout = !!pos_config.iface_self_checkout;
156 self.iface_cashdrawer = !!pos_config.iface_cashdrawer;
158 return self.fetch('sale.shop',[],[['id','=',pos_config.shop_id[0]]]);
159 }).then(function(shops){
160 self.set('shop',shops[0]);
162 return self.fetch('product.packaging',['ean','product_id']);
163 }).then(function(packagings){
164 self.db.add_packagings(packagings);
166 return self.fetch('pos.category', ['id','name','parent_id','child_id','image'])
167 }).then(function(categories){
168 self.db.add_categories(categories);
172 ['name', 'list_price','price','pos_categ_id', 'taxes_id', 'ean13',
173 'to_weight', 'uom_id', 'uos_id', 'uos_coeff', 'mes_type', 'description_sale', 'description'],
174 [['sale_ok','=',true],['available_in_pos','=',true]],
175 {pricelist: self.get('shop').pricelist_id[0]} // context for price
177 }).then(function(products){
178 self.db.add_products(products);
181 'account.bank.statement',
182 ['account_id','currency','journal_id','state','name','user_id','pos_session_id'],
183 [['state','=','open'],['pos_session_id', '=', self.get('pos_session').id]]
185 }).then(function(bank_statements){
186 var journals = new Array();
187 _.each(bank_statements,function(statement) {
188 journals.push(statement.journal_id[0])
190 self.set('bank_statements', bank_statements);
191 return self.fetch('account.journal', undefined, [['id','in', journals]]);
192 }).then(function(journals){
193 self.set('journals',journals);
195 // associate the bank statements with their journals.
196 var bank_statements = self.get('bank_statements');
197 for(var i = 0, ilen = bank_statements.length; i < ilen; i++){
198 for(var j = 0, jlen = journals.length; j < jlen; j++){
199 if(bank_statements[i].journal_id[0] === journals[j].id){
200 bank_statements[i].journal = journals[j];
201 bank_statements[i].self_checkout_payment_method = journals[j].self_checkout_payment_method;
205 self.set({'cashRegisters' : new module.CashRegisterCollection(self.get('bank_statements'))});
211 // logs the usefull posmodel data to the console for debug purposes
212 log_loaded_data: function(){
213 console.log('PosModel data has been loaded:');
214 console.log('PosModel: categories:',this.get('categories'));
215 console.log('PosModel: units:',this.get('units'));
216 console.log('PosModel: bank_statements:',this.get('bank_statements'));
217 console.log('PosModel: journals:',this.get('journals'));
218 console.log('PosModel: taxes:',this.get('taxes'));
219 console.log('PosModel: pos_session:',this.get('pos_session'));
220 console.log('PosModel: pos_config:',this.get('pos_config'));
221 console.log('PosModel: cashRegisters:',this.get('cashRegisters'));
222 console.log('PosModel: shop:',this.get('shop'));
223 console.log('PosModel: company:',this.get('company'));
224 console.log('PosModel: currency:',this.get('currency'));
225 console.log('PosModel: user_list:',this.get('user_list'));
226 console.log('PosModel: user:',this.get('user'));
227 console.log('PosModel.session:',this.session);
228 console.log('PosModel end of data log.');
231 // this is called when an order is removed from the order collection. It ensures that there is always an existing
232 // order and a valid selected order
233 on_removed_order: function(removed_order){
234 if( this.get('orders').isEmpty()){
235 this.add_new_order();
237 if( this.get('selectedOrder') === removed_order){
238 this.set({ selectedOrder: this.get('orders').last() });
242 // saves the order locally and try to send it to the backend. 'record' is a bizzarely defined JSON version of the Order
243 push_order: function(record) {
244 this.db.add_order(record);
248 //creates a new empty order and sets it as the current order
249 add_new_order: function(){
250 var order = new module.Order({pos:this});
251 this.get('orders').add(order);
252 this.set('selectedOrder', order);
255 // attemps to send all pending orders ( stored in the pos_db ) to the server,
256 // and remove the successfully sent ones from the db once
257 // it has been confirmed that they have been sent correctly.
259 //TODO make the mutex work
260 //this makes sure only one _int_flush is called at the same time
262 return this.flush_mutex.exec(_.bind(function() {
263 return this._flush(0);
268 // attempts to send an order of index 'index' in the list of order to send. The index
269 // is used to skip orders that failed. do not call this method outside the mutex provided
271 _flush: function(index){
273 var orders = this.db.get_orders();
274 self.set('nbr_pending_operations',orders.length);
276 var order = orders[index];
280 //try to push an order to the server
281 (new instance.web.Model('pos.order')).get_func('create_from_ui')([order])
282 .fail(function(unused, event){
283 //don't show error popup if it fails
284 event.preventDefault();
285 console.error('Failed to send order:',order);
286 self._flush(index+1);
289 //remove from db if success
290 self.db.remove_order(order.id);
295 scan_product: function(parsed_ean){
297 var product = this.db.get_product_by_ean13(parsed_ean.base_ean);
298 var selectedOrder = this.get('selectedOrder');
304 if(parsed_ean.type === 'price'){
305 selectedOrder.addProduct(new module.Product(product), {price:parsed_ean.value});
306 }else if(parsed_ean.type === 'weight'){
307 selectedOrder.addProduct(new module.Product(product), {quantity:parsed_ean.value, merge:false});
309 selectedOrder.addProduct(new module.Product(product));
315 module.CashRegister = Backbone.Model.extend({
318 module.CashRegisterCollection = Backbone.Collection.extend({
319 model: module.CashRegister,
322 module.Product = Backbone.Model.extend({
323 get_image_url: function(){
324 return instance.session.url('/web/binary/image', {model: 'product.product', field: 'image', id: this.get('id')});
328 module.ProductCollection = Backbone.Collection.extend({
329 model: module.Product,
332 // An orderline represent one element of the content of a client's shopping cart.
333 // An orderline contains a product, its quantity, its price, discount. etc.
334 // An Order contains zero or more Orderlines.
335 module.Orderline = Backbone.Model.extend({
336 initialize: function(attr,options){
337 this.pos = options.pos;
338 this.order = options.order;
339 this.product = options.product;
340 this.price = options.product.get('price');
344 this.selected = false;
346 // sets a discount [0,100]%
347 set_discount: function(discount){
348 this.discount = Math.max(0,Math.min(100,discount));
349 this.trigger('change');
351 // returns the discount [0,100]%
352 get_discount: function(){
353 return this.discount;
355 get_product_type: function(){
358 // sets the quantity of the product. The quantity will be rounded according to the
359 // product's unity of measure properties. Quantities greater than zero will not get
361 set_quantity: function(quantity){
362 if(_.isNaN(quantity)){
363 this.order.removeOrderline(this);
364 }else if(quantity !== undefined){
365 this.quantity = Math.max(0,quantity);
366 var unit = this.get_unit();
367 if(unit && this.quantity > 0 ){
368 this.quantity = Math.max(unit.rounding, Math.round(quantity / unit.rounding) * unit.rounding);
371 this.trigger('change');
373 // return the quantity of product
374 get_quantity: function(){
375 return this.quantity;
377 // return the unit of measure of the product
378 get_unit: function(){
379 var unit_id = (this.product.get('uos_id') || this.product.get('uom_id'));
383 unit_id = unit_id[0];
387 return this.pos.get('units_by_id')[unit_id];
389 // return the product of this orderline
390 get_product: function(){
393 // return the base price of this product (for this orderline)
394 get_price: function(){
397 // changes the base price of the product for this orderline
398 set_price: function(price){
400 this.trigger('change');
402 // selects or deselects this orderline
403 set_selected: function(selected){
404 this.selected = selected;
405 this.trigger('change');
407 // returns true if this orderline is selected
408 is_selected: function(){
409 return this.selected;
411 // when we add an new orderline we want to merge it with the last line to see reduce the number of items
412 // in the orderline. This returns true if it makes sense to merge the two
413 can_be_merged_with: function(orderline){
414 if( this.get_product().get('id') !== orderline.get_product().get('id')){ //only orderline of the same product can be merged
416 }else if(this.get_product_type() !== orderline.get_product_type()){
418 }else if(this.get_discount() > 0){ // we don't merge discounted orderlines
420 }else if(this.price !== orderline.price){
426 merge: function(orderline){
427 this.set_quantity(this.get_quantity() + orderline.get_quantity());
429 export_as_JSON: function() {
431 qty: this.get_quantity(),
432 price_unit: this.get_price(),
433 discount: this.get_discount(),
434 product_id: this.get_product().get('id'),
437 //used to create a json of the ticket, to be sent to the printer
438 export_for_printing: function(){
440 quantity: this.get_quantity(),
441 unit_name: this.get_unit().name,
442 price: this.get_price(),
443 discount: this.get_discount(),
444 product_name: this.get_product().get('name'),
445 price_with_tax : this.get_price_with_tax(),
446 price_without_tax: this.get_price_without_tax(),
448 product_description: this.get_product().get('description'),
449 product_description_sale: this.get_product().get('description_sale'),
452 get_price_without_tax: function(){
453 return this.get_all_prices().priceWithoutTax;
455 get_price_with_tax: function(){
456 return this.get_all_prices().priceWithTax;
459 return this.get_all_prices().tax;
461 get_all_prices: function() {
463 var base = this.get_quantity() * this.price * (1 - (this.get_discount() / 100));
465 var totalNoTax = base;
467 var product_list = this.pos.get('product_list');
468 var product = this.get_product();
469 var taxes_ids = product.get('taxes_id');;
470 var taxes = self.pos.get('taxes');
472 _.each(taxes_ids, function(el) {
473 var tax = _.detect(taxes, function(t) {return t.id === el;});
474 if (tax.price_include) {
476 if (tax.type === "percent") {
477 tmp = base - (base / (1 + tax.amount));
478 } else if (tax.type === "fixed") {
479 tmp = tax.amount * self.get_quantity();
481 throw "This type of tax is not supported by the point of sale: " + tax.type;
487 if (tax.type === "percent") {
488 tmp = tax.amount * base;
489 } else if (tax.type === "fixed") {
490 tmp = tax.amount * self.get_quantity();
492 throw "This type of tax is not supported by the point of sale: " + tax.type;
499 "priceWithTax": totalTax,
500 "priceWithoutTax": totalNoTax,
506 module.OrderlineCollection = Backbone.Collection.extend({
507 model: module.Orderline,
510 // Every PaymentLine contains a cashregister and an amount of money.
511 module.Paymentline = Backbone.Model.extend({
512 initialize: function(attributes, options) {
514 this.cashregister = options.cashRegister;
516 //sets the amount of money on this payment line
517 set_amount: function(value){
519 this.trigger('change');
521 // returns the amount of money on this paymentline
522 get_amount: function(){
525 // returns the associated cashRegister
526 get_cashregister: function(){
527 return this.cashregister;
529 //exports as JSON for server communication
530 export_as_JSON: function(){
532 name: instance.web.datetime_to_str(new Date()),
533 statement_id: this.cashregister.get('id'),
534 account_id: (this.cashregister.get('account_id'))[0],
535 journal_id: (this.cashregister.get('journal_id'))[0],
536 amount: this.get_amount()
539 //exports as JSON for receipt printing
540 export_for_printing: function(){
542 amount: this.get_amount(),
543 journal: this.cashregister.get('journal_id')[1],
548 module.PaymentlineCollection = Backbone.Collection.extend({
549 model: module.Paymentline,
553 // An order more or less represents the content of a client's shopping cart (the OrderLines)
554 // plus the associated payment information (the PaymentLines)
555 // there is always an active ('selected') order in the Pos, a new one is created
556 // automaticaly once an order is completed and sent to the server.
557 module.Order = Backbone.Model.extend({
558 initialize: function(attributes){
559 Backbone.Model.prototype.initialize.apply(this, arguments);
561 creationDate: new Date(),
562 orderLines: new module.OrderlineCollection(),
563 paymentLines: new module.PaymentlineCollection(),
564 name: "Order " + this.generateUniqueId(),
567 this.pos = attributes.pos;
568 this.selected_orderline = undefined;
569 this.screen_data = {}; // see ScreenSelector
570 this.receipt_type = 'receipt'; // 'receipt' || 'invoice'
573 generateUniqueId: function() {
574 return new Date().getTime();
576 addProduct: function(product, options){
577 options = options || {};
578 var attr = product.toJSON();
581 var line = new module.Orderline({}, {pos: this.pos, order: this, product: product});
583 if(options.quantity !== undefined){
584 line.set_quantity(options.quantity);
586 if(options.price !== undefined){
587 line.set_price(options.price);
590 var last_orderline = this.getLastOrderline();
591 if( last_orderline && last_orderline.can_be_merged_with(line) && options.merge !== false){
592 last_orderline.merge(line);
594 this.get('orderLines').add(line);
596 this.selectLine(this.getLastOrderline());
598 removeOrderline: function( line ){
599 this.get('orderLines').remove(line);
600 this.selectLine(this.getLastOrderline());
602 getLastOrderline: function(){
603 return this.get('orderLines').at(this.get('orderLines').length -1);
605 addPaymentLine: function(cashRegister) {
606 var paymentLines = this.get('paymentLines');
607 var newPaymentline = new module.Paymentline({},{cashRegister:cashRegister});
608 if(cashRegister.get('journal').type !== 'cash'){
609 newPaymentline.set_amount( this.getDueLeft() );
611 paymentLines.add(newPaymentline);
613 getName: function() {
614 return this.get('name');
616 getTotal: function() {
617 return (this.get('orderLines')).reduce((function(sum, orderLine) {
618 return sum + orderLine.get_price_with_tax();
621 getDiscountTotal: function() {
622 return (this.get('orderLines')).reduce((function(sum, orderLine) {
623 return sum + (orderLine.get_price() * (orderLine.get_discount()/100) * orderLine.get_quantity());
626 getTotalTaxExcluded: function() {
627 return (this.get('orderLines')).reduce((function(sum, orderLine) {
628 return sum + orderLine.get_price_without_tax();
632 return (this.get('orderLines')).reduce((function(sum, orderLine) {
633 return sum + orderLine.get_tax();
636 getPaidTotal: function() {
637 return (this.get('paymentLines')).reduce((function(sum, paymentLine) {
638 return sum + paymentLine.get_amount();
641 getChange: function() {
642 return this.getPaidTotal() - this.getTotal();
644 getDueLeft: function() {
645 return this.getTotal() - this.getPaidTotal();
647 // sets the type of receipt 'receipt'(default) or 'invoice'
648 set_receipt_type: function(type){
649 this.receipt_type = type;
651 get_receipt_type: function(){
652 return this.receipt_type;
654 // the client related to the current order.
655 set_client: function(client){
656 this.set('client',client);
658 get_client: function(){
659 return this.get('client');
661 get_client_name: function(){
662 var client = this.get('client');
663 return client ? client.name : "";
665 // the order also stores the screen status, as the PoS supports
666 // different active screens per order. This method is used to
667 // store the screen status.
668 set_screen_data: function(key,value){
669 if(arguments.length === 2){
670 this.screen_data[key] = value;
671 }else if(arguments.length === 1){
672 for(key in arguments[0]){
673 this.screen_data[key] = arguments[0][key];
677 //see set_screen_data
678 get_screen_data: function(key){
679 return this.screen_data[key];
681 // exports a JSON for receipt printing
682 export_for_printing: function(){
684 this.get('orderLines').each(function(orderline){
685 orderlines.push(orderline.export_for_printing());
688 var paymentlines = [];
689 this.get('paymentLines').each(function(paymentline){
690 paymentlines.push(paymentline.export_for_printing());
692 var client = this.get('client');
693 var cashier = this.pos.get('cashier') || this.pos.get('user');
694 var company = this.pos.get('company');
695 var shop = this.pos.get('shop');
696 var date = new Date();
699 orderlines: orderlines,
700 paymentlines: paymentlines,
701 total_with_tax: this.getTotal(),
702 total_without_tax: this.getTotalTaxExcluded(),
703 total_tax: this.getTax(),
704 total_paid: this.getPaidTotal(),
705 change: this.getChange(),
706 name : this.getName(),
707 client: client ? client.name : null ,
708 invoice_id: null, //TODO
709 cashier: cashier ? cashier.name : null,
711 year: date.getFullYear(),
712 month: date.getMonth(),
713 date: date.getDate(), // day of the month
714 day: date.getDay(), // day of the week
715 hour: date.getHours(),
716 minute: date.getMinutes()
719 email: company.email,
720 website: company.website,
721 company_registry: company.company_registry,
722 contact_address: company.contact_address,
725 phone: company.phone,
730 currency: this.pos.get('currency'),
733 exportAsJSON: function() {
734 var orderLines, paymentLines;
736 (this.get('orderLines')).each(_.bind( function(item) {
737 return orderLines.push([0, 0, item.export_as_JSON()]);
740 (this.get('paymentLines')).each(_.bind( function(item) {
741 return paymentLines.push([0, 0, item.export_as_JSON()]);
744 name: this.getName(),
745 amount_paid: this.getPaidTotal(),
746 amount_total: this.getTotal(),
747 amount_tax: this.getTax(),
748 amount_return: this.getChange(),
750 statement_ids: paymentLines,
751 pos_session_id: this.pos.get('pos_session').id,
752 partner_id: this.pos.get('client') ? this.pos.get('client').id : undefined,
753 user_id: this.pos.get('cashier') ? this.pos.get('cashier').id : this.pos.get('user').id,
756 getSelectedLine: function(){
757 return this.selected_orderline;
759 selectLine: function(line){
761 if(line !== this.selected_orderline){
762 if(this.selected_orderline){
763 this.selected_orderline.set_selected(false);
765 this.selected_orderline = line;
766 this.selected_orderline.set_selected(true);
769 this.selected_orderline = undefined;
774 module.OrderCollection = Backbone.Collection.extend({
779 The numpad handles both the choice of the property currently being modified
780 (quantity, price or discount) and the edition of the corresponding numeric value.
782 module.NumpadState = Backbone.Model.extend({
787 appendNewChar: function(newChar) {
789 oldBuffer = this.get('buffer');
790 if (oldBuffer === '0') {
794 } else if (oldBuffer === '-0') {
796 buffer: "-" + newChar
800 buffer: (this.get('buffer')) + newChar
805 deleteLastChar: function() {
806 var tempNewBuffer = this.get('buffer').slice(0, -1);
809 this.set({ buffer: "0" });
812 if (isNaN(tempNewBuffer)) {
815 this.set({ buffer: tempNewBuffer });
819 switchSign: function() {
821 oldBuffer = this.get('buffer');
823 buffer: oldBuffer[0] === '-' ? oldBuffer.substr(1) : "-" + oldBuffer
827 changeMode: function(newMode) {
839 updateTarget: function() {
840 var bufferContent, params;
841 bufferContent = this.get('buffer');
842 if (bufferContent && !isNaN(bufferContent)) {
843 this.trigger('set_value', parseFloat(bufferContent));
846 killTarget: function(){
847 this.trigger('set_value',Number.NaN);