diff --git a/addons/account/account.py b/addons/account/account.py
index 555d54c3ece..d2edea00d17 100644
--- a/addons/account/account.py
+++ b/addons/account/account.py
@@ -752,6 +752,7 @@ class account_journal(osv.osv):
'internal_account_id' : fields.many2one('account.account', 'Internal Transfers Account', select=1),
'cash_control' : fields.boolean('Cash Control', help='If you want the journal should be control at opening/closing, check this option'),
'analytic_journal_id':fields.many2one('account.analytic.journal','Analytic Journal', help="Journal for analytic entries"),
+ 'sequence': fields.integer('Sequence',help='Used to order Journals'),
}
_defaults = {
@@ -759,13 +760,14 @@ class account_journal(osv.osv):
'with_last_closing_balance' : True,
'user_id': lambda self, cr, uid, context: uid,
'company_id': lambda self, cr, uid, c: self.pool.get('res.users').browse(cr, uid, uid, c).company_id.id,
+ 'sequence': 1,
}
_sql_constraints = [
('code_company_uniq', 'unique (code, company_id)', 'The code of the journal must be unique per company !'),
('name_company_uniq', 'unique (name, company_id)', 'The name of the journal must be unique per company !'),
]
- _order = 'code'
+ _order = 'sequence,code'
def _check_currency(self, cr, uid, ids, context=None):
for journal in self.browse(cr, uid, ids, context=context):
diff --git a/addons/account/account_view.xml b/addons/account/account_view.xml
index 6215103bba7..e4d0e9cf22c 100644
--- a/addons/account/account_view.xml
+++ b/addons/account/account_view.xml
@@ -360,6 +360,7 @@
account.journal
+
diff --git a/addons/account_anglo_saxon/test/anglo_saxon.yml b/addons/account_anglo_saxon/test/anglo_saxon.yml
index ca082aea97c..b3588ad6621 100644
--- a/addons/account_anglo_saxon/test/anglo_saxon.yml
+++ b/addons/account_anglo_saxon/test/anglo_saxon.yml
@@ -212,8 +212,9 @@
product_uom_qty: 1.0
product_uom: product.product_uom_unit
location_dest_id: stock.stock_location_customers
- move_type: direct
+ invoice_state: 2binvoiced
invoice_state: 2binvoiced
+ move_type: direct
picking_type_id: stock.picking_type_out
-
I need to check the availability of the product, So I make my picking order for processing later.
diff --git a/addons/account_anglo_saxon/test/anglo_saxon_avg_fifo.yml b/addons/account_anglo_saxon/test/anglo_saxon_avg_fifo.yml
index b6c5d57d37f..f819fcdae7c 100644
--- a/addons/account_anglo_saxon/test/anglo_saxon_avg_fifo.yml
+++ b/addons/account_anglo_saxon/test/anglo_saxon_avg_fifo.yml
@@ -214,6 +214,7 @@
product_id: product_fifo_anglo_saxon
product_uom_qty: 1.0
location_dest_id: stock.stock_location_customers
+ invoice_state: 2binvoiced
invoice_state: 2binvoiced
move_type: direct
picking_type_id: stock.picking_type_out
diff --git a/addons/barcodes/__init__.py b/addons/barcodes/__init__.py
new file mode 100644
index 00000000000..16d15ce9bfe
--- /dev/null
+++ b/addons/barcodes/__init__.py
@@ -0,0 +1,2 @@
+import barcodes
+
diff --git a/addons/barcodes/__openerp__.py b/addons/barcodes/__openerp__.py
new file mode 100644
index 00000000000..b5300bdd660
--- /dev/null
+++ b/addons/barcodes/__openerp__.py
@@ -0,0 +1,31 @@
+{
+ 'name': 'Barcodes',
+ 'version': '1.0',
+ 'category': '',
+ 'sequence': 6,
+ 'summary': 'Barcode Nomenclatures Setup',
+ 'description': """
+
+=======================
+
+This module defines barcode nomenclatures whose rules identify e.g. products, locations.
+It contains the following features:
+- Barcode patterns to identify barcodes containing a numerical value (e.g. weight, price)
+- Definitin of barcode aliases that allow to identify the same product with different barcodes
+- Unlimited barcode patterns and definitions.
+- Barcode EAN13 encoding supported
+""",
+ 'author': 'OpenERP SA',
+ 'depends': ['web'],
+ 'website': '',
+ 'data': [
+ 'data/barcodes_data.xml',
+ 'barcodes_view.xml',
+ 'security/ir.model.access.csv',
+ 'views/templates.xml',
+ ],
+ 'installable': True,
+ 'auto_install': False,
+}
+
+# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
diff --git a/addons/barcodes/barcodes.py b/addons/barcodes/barcodes.py
new file mode 100644
index 00000000000..ad72084bc03
--- /dev/null
+++ b/addons/barcodes/barcodes.py
@@ -0,0 +1,177 @@
+import logging
+import re
+
+import openerp
+from openerp import tools, models, fields, api
+from openerp.osv import fields, osv
+from openerp.tools.translate import _
+from openerp.exceptions import ValidationError
+
+_logger = logging.getLogger(__name__)
+
+class barcode_nomenclature(osv.osv):
+ _name = 'barcode.nomenclature'
+ _columns = {
+ 'name': fields.char('Nomenclature Name', size=32, required=True, help='An internal identification of the barcode nomenclature'),
+ 'rule_ids': fields.one2many('barcode.rule','barcode_nomenclature_id','Rules', help='The list of barcode rules'),
+ 'strict_ean': fields.boolean('Use strict EAN13',
+ help='Many barcode scanners strip the leading zero of EAN13 barcodes. By using strict EAN13, we consider the scanned barcode directly. Otherwise, we prepend scanned barcodes of length 12 by a zero before looking for the associated item.')
+ }
+
+ _defaults = {
+ 'strict_ean': False,
+ }
+
+ def use_strict_ean(self):
+ return self.strict_ean
+
+ # returns the checksum of the ean13, or -1 if the ean has not the correct length, ean must be a string
+ def ean_checksum(self, ean):
+ code = list(ean)
+ if len(code) != 13:
+ return -1
+
+ oddsum = evensum = total = 0
+ code = code[:-1] # Remove checksum
+ for i in range(len(code)):
+ if i % 2 == 0:
+ evensum += int(code[i])
+ else:
+ oddsum += int(code[i])
+ total = oddsum * 3 + evensum
+ return int((10 - total % 10) % 10)
+
+ # returns true if the barcode is a valid EAN barcode
+ def check_ean(self, ean):
+ return re.match("^\d+$", ean) and self.ean_checksum(ean) == int(ean[len(ean)-1])
+
+ # Returns a valid zero padded ean13 from an ean prefix. the ean prefix must be a string.
+ def sanitize_ean(self, ean):
+ ean = ean[0:13]
+ ean = ean + (13-len(ean))*'0'
+ return ean[0:12] + str(self.ean_checksum(ean))
+
+ # Attempts to interpret an barcode (string encoding a barcode)
+ # It will return an object containing various information about the barcode.
+ # most importantly :
+ # - code : the barcode
+ # - type : the type of the barcode:
+ # - value : if the id encodes a numerical value, it will be put there
+ # - base_code : the barcode code with all the encoding parts set to zero; the one put on
+ # the product in the backend
+ def parse_barcode(self, barcode):
+ parsed_result = {
+ 'encoding': '',
+ 'type': 'error',
+ 'code': barcode,
+ 'base_code': barcode,
+ 'value': 0}
+
+ # Checks if barcode matches the pattern
+ # Additionnaly retrieves the optional numerical content in barcode
+ # Returns an object containing:
+ # - value: the numerical value encoded in the barcode (0 if no value encoded)
+ # - base_code: the barcode in which numerical content is replaced by 0's
+ # - match: boolean
+ def match_pattern(barcode, pattern):
+ match = {
+ "value": 0,
+ "base_code": barcode,
+ "match": False,
+ }
+
+ barcode = barcode.replace("\\", "\\\\").replace("{", '\{').replace("}", "\}").replace(".", "\.")
+ numerical_content = re.search("[{][N]*[D]*[}]", pattern) # look for numerical content in pattern
+
+ if numerical_content: # the pattern encodes a numerical content
+ num_start = numerical_content.start() # start index of numerical content
+ num_end = numerical_content.end() # end index of numerical content
+ value_string = barcode[num_start:num_end-2] # numerical content in barcode
+
+ whole_part_match = re.search("[{][N]*[D}]", numerical_content.group()) # looks for whole part of numerical content
+ decimal_part_match = re.search("[{N][D]*[}]", numerical_content.group()) # looks for decimal part
+ whole_part = value_string[:whole_part_match.end()-2] # retrieve whole part of numerical content in barcode
+ decimal_part = "0." + value_string[decimal_part_match.start():decimal_part_match.end()-1] # retrieve decimal part
+ if whole_part == '':
+ whole_part = '0'
+ match['value'] = int(whole_part) + float(decimal_part)
+
+ match['base_code'] = barcode[:num_start] + (num_end-num_start-2)*"0" + barcode[num_end-2:] # replace numerical content by 0's in barcode
+ match['base_code'] = match['base_code'].replace("\\\\", "\\").replace("\{", "{").replace("\}","}").replace("\.",".")
+ pattern = pattern[:num_start] + (num_end-num_start-2)*"0" + pattern[num_end:] # replace numerical content by 0's in pattern to match
+
+ match['match'] = re.match(pattern, match['base_code'][:len(pattern)])
+
+ return match
+
+
+ rules = []
+ for rule in self.rule_ids:
+ rules.append({'type': rule.type, 'encoding': rule.encoding, 'sequence': rule.sequence, 'pattern': rule.pattern, 'alias': rule.alias})
+
+ # If the nomenclature does not use strict EAN, prepend the barcode with a 0 if it seems
+ # that it has been striped by the barcode scanner, when trying to match an EAN13 rule
+ prepend_zero = False
+ if not self.strict_ean and len(barcode) == 12 and self.check_ean("0"+barcode):
+ prepend_zero = True
+
+ for rule in rules:
+ cur_barcode = barcode
+ if prepend_zero and rule['encoding'] == "ean13":
+ cur_barcode = '0'+cur_barcode
+
+ match = match_pattern(cur_barcode, rule['pattern'])
+ if match['match']:
+ if rule['type'] == 'alias':
+ barcode = rule['alias']
+ parsed_result['code'] = barcode
+ else:
+ parsed_result['encoding'] = rule['encoding']
+ parsed_result['type'] = rule['type']
+ parsed_result['value'] = match['value']
+ parsed_result['code'] = cur_barcode
+ if rule['encoding'] == "ean13":
+ parsed_result['base_code'] = self.sanitize_ean(match['base_code'])
+ else:
+ parsed_result['base_code'] = match['base_code']
+ return parsed_result
+
+ return parsed_result
+
+class barcode_rule(models.Model):
+ _name = 'barcode.rule'
+ _order = 'sequence asc'
+
+ @api.model
+ def _get_type_selection(self):
+ return [('alias','Alias'),('product','Unit Product')]
+
+ _columns = {
+ 'name': fields.char('Rule Name', size=32, required=True, help='An internal identification for this barcode nomenclature rule'),
+ 'barcode_nomenclature_id': fields.many2one('barcode.nomenclature','Barcode Nomenclature'),
+ 'sequence': fields.integer('Sequence', help='Used to order rules such that rules with a smaller sequence match first'),
+ 'encoding': fields.selection([('any','Any'),('ean13','EAN-13')],'Encoding',required=True,help='This rule will apply only if the barcode is encoded with the specified encoding'),
+ 'type': fields.selection('_get_type_selection','Type', required=True),
+ 'pattern': fields.char('Barcode Pattern', size=32, help="The barcode matching pattern"),
+ 'alias': fields.char('Alias',size=32,help='The matched pattern will alias to this barcode',required=True),
+ }
+
+ _defaults = {
+ 'type': 'product',
+ 'pattern': '*',
+ 'encoding': 'any',
+ 'alias': "0",
+ }
+
+ @api.one
+ @api.constrains('pattern')
+ def _check_pattern(self):
+ p = self.pattern.replace("\\\\", "X").replace("\{", "X").replace("\}", "X")
+ findall = re.findall("[{]|[}]", p) # p does not contain escaped { or }
+ if len(findall) == 2:
+ if not re.search("[{][N]*[D]*[}]", p):
+ raise ValidationError(_("There is a syntax error in the barcode pattern ") + self.pattern + _(": braces can only contain N's followed by D's."))
+ elif re.search("[{][}]", p):
+ raise ValidationError(_("There is a syntax error in the barcode pattern ") + self.pattern + _(": empty braces."))
+ elif len(findall) != 0:
+ raise ValidationError(_("There is a syntax error in the barcode pattern ") + self.pattern + _(": a rule can only contain one pair of braces."))
diff --git a/addons/barcodes/barcodes_view.xml b/addons/barcodes/barcodes_view.xml
new file mode 100644
index 00000000000..e3089837674
--- /dev/null
+++ b/addons/barcodes/barcodes_view.xml
@@ -0,0 +1,86 @@
+
+
+
+
+
+ Barcode Nomenclatures
+ barcode.nomenclature
+
+
+
+
+
+
+ Barcode Nomenclatures
+ barcode.nomenclature
+
+
+
+
+
+
+
+
+ Barcode Nomenclatures
+ ir.actions.act_window
+ barcode.nomenclature
+ form
+ tree,form
+
+
+ Click to add a Barcode Nomenclature .
+
+ A barcode nomenclature defines how the point of sale identify and interprets barcodes
+
+
+
+
+
+ Barcode Rule
+ barcode.rule
+
+
+
+
+
+
diff --git a/addons/barcodes/data/barcodes_data.xml b/addons/barcodes/data/barcodes_data.xml
new file mode 100644
index 00000000000..2c8b711ba67
--- /dev/null
+++ b/addons/barcodes/data/barcodes_data.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ Default Nomenclature
+
+
+
+ Product Barcodes
+
+ 90
+ product
+ any
+ *
+
+
+
diff --git a/addons/barcodes/doc/index.rst b/addons/barcodes/doc/index.rst
new file mode 100644
index 00000000000..6cdacdc3a11
--- /dev/null
+++ b/addons/barcodes/doc/index.rst
@@ -0,0 +1,75 @@
+Barcodes module documentation
+==============================
+
+The Barcodes module defines barcode nomenclatures whose rules identify specific type
+of items e.g. products, locations. It contains the following features:
+
+- Barcode patterns to identify barcodes containing a numerical value (e.g. weight, price)
+- Definitin of barcode aliases that allow to identify the same product with different barcodes
+- Unlimited barcode patterns and definitions,
+- Barcode EAN13 encoding supported.
+
+Barcode encodings
+-----------------
+
+A barcode is an arbitrary long sequence of ASCII characters. An EAN-13 barcode is a 13 digit
+barcode, whose 13th digit is the checksum.
+
+Simple barcodes and rules
+-------------------------
+
+The default nomenclature assumes an EAN-13 encoding for product barcodes. It defines a rule
+for Unit Products whose encoding is EAN-13, and whose pattern is '.', i.e. any barcode
+matches this pattern. Scanning the barcode of a product, say '5410013101703', matches this rule.
+The scanned item is thus identified as a Unit Product, and is retrieved from the product table.
+
+Note: the special character '.' in patterns is matched by any character. To explicitely specify
+the '.' character in a pattern, escape it with '\'. The '\' character has to be escaped as well
+('\\') to be explicitely specified.
+
+Let us now suppose that we identify other items with barcodes, say stock locations. We define a
+new rule in the nomenclature with the corresponding type (in our example, the type is 'Location'),
+and whose pattern is e.g. '414.', that is, any location barcode starts with '414'. Scanning a barcode
+location, say '41401', matches this Location rule, and the corresponding location is retrieved from
+the location table.
+
+Note: Rules have a sequence field which indicates the order the rules are evaluated (ASC). In our
+previous examples, the Unit Product rule should have a larger sequence that then Location rule,
+because we want the latter one to be evaluated first.
+
+Barcodes with numerical content
+--------------------------------
+
+Barcodes may encode numerical content, which is decoded by the barcodes module. To that purpose,
+one have to define a new rule for barcodes with numerical content (e.g. barcodes for Weighted
+Products). The numerical content in a pattern is specified between braces (special characters '{' and
+'}'). The content of the braces must be a sequence of 'N's (representing the whole part of the numerical
+content) followed by a sequence of 'D's (representing the decimal part of the numerical content).
+For instance, let us define a new rule for Weighted Products whose pattern is '21.....{NNDDD}.'. Since
+we assume EAN-13 encoding for product barcodes, the encoding of this rule should be EAN-13 as well.
+
+Let us now assume that we want to write a barcode for a given Weighted Product, say oranges. We first
+have to define in product oranges a barcode that will match the Weighted Product rule. This barcode
+must start with '21' and be a correct EAN-13 barcode (i.e. the 13th digit must be a correct checksum).
+Moreover, all the numerical content must be set to a '0'. For instance, let us set the barcode to
+'2100001000004'.
+
+We now want to write a barcode for 2.75kg of oranges. This barcode should be '2100001027506' (the
+numerical content of this barcode is '02750', and the correct checksum is '6'). When scanned, this
+barcode matches the Weighted Product rule (since is starts with '21'). The numerical content is extracted,
+and replaced by a sequence of '0's. The correct checksum is then computed for the obtained barcode
+('2100001000004') and the corresponding product (oranges) qgit is retrieved from product table.
+
+Note: the special characters '{' and '}' in patterns are used to identify numerical content. To
+explicitely specify '{' or '}' in a pattern, they must be escaped.
+
+
+Strict EAN-13 field of barcode nomenclatures
+--------------------------------------------
+
+Many barcode scanners strip the leading zero when scanning EAN-13 barcodes. Barcode nomenclatures
+have a boolean field "Use strict EAN13". If False, when trying to match a scanned barcode with
+a rule whose encoding is EAN-13, if the barcode is of length 12 and, by prepending it by a 0,
+the last digit is the correct checksum, we automatically prepend the barcode by 0 and try to
+find a match with this new barcode. If "Use strict EAN13" is set to True, we look for a pattern
+matching the original, 12-digit long, barcode.
diff --git a/addons/barcodes/security/ir.model.access.csv b/addons/barcodes/security/ir.model.access.csv
new file mode 100644
index 00000000000..30ba7441424
--- /dev/null
+++ b/addons/barcodes/security/ir.model.access.csv
@@ -0,0 +1,5 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_barcode_nomenclature_user,barcode.nomenclature.user,model_barcode_nomenclature,base.group_user,1,0,0,0
+access_barcode_nomenclature_manager,barcode.nomenclature.manager,model_barcode_nomenclature,base.group_erp_manager,1,1,1,1
+access_barcode_rule_user,barcode.rule.user,model_barcode_rule,base.group_user,1,0,0,0
+access_barcode_rule_manager,barcode.rule.manager,model_barcode_rule,base.group_erp_manager,1,1,1,1
diff --git a/addons/barcodes/static/src/js/barcode_parser.js b/addons/barcodes/static/src/js/barcode_parser.js
new file mode 100644
index 00000000000..3ce07be1755
--- /dev/null
+++ b/addons/barcodes/static/src/js/barcode_parser.js
@@ -0,0 +1,184 @@
+openerp.barcodes = function(instance) {
+ "use strict";
+
+ instance.barcodes = {};
+ var module = instance.barcodes;
+
+ // The BarcodeParser is used to detect what is the category
+ // of a barcode (product, partner, ...) and extract an encoded value
+ // (like weight, price, etc.)
+ module.BarcodeParser = instance.web.Class.extend({
+ init: function(attributes) {
+ var self = this;
+ this.nomenclature_id = attributes.nomenclature_id;
+ this.loaded = this.load();
+ },
+
+ // This loads the barcode nomenclature and barcode rules which are
+ // necessary to parse the barcodes. The BarcodeParser is operational
+ // only when those data have been loaded
+ load: function(){
+ var self = this;
+ return new instance.web.Model('barcode.nomenclature')
+ .query(['name','rule_ids','strict_ean'])
+ .filter([['id','=',this.nomenclature_id[0]]])
+ .first()
+ .then(function(nomenclature){
+ self.nomenclature = nomenclature;
+
+ return new instance.web.Model('barcode.rule')
+ .query(['name','sequence','type','encoding','pattern','alias'])
+ .filter([['barcode_nomenclature_id','=',self.nomenclature.id ]])
+ .all()
+ }).then(function(rules){
+ rules = rules.sort(function(a,b){ return a.sequence - b.sequence; });
+ self.nomenclature.rules = rules;
+ });
+ },
+
+ // resolves when the barcode parser is operational.
+ is_loaded: function() {
+ return self.loaded;
+ },
+
+ // returns the checksum of the ean13, or -1 if the ean has not the correct length, ean must be a string
+ ean_checksum: function(ean){
+ var code = ean.split('');
+ if(code.length !== 13){
+ return -1;
+ }
+ var oddsum = 0, evensum = 0, total = 0;
+ code = code.reverse().splice(1);
+ for(var i = 0; i < code.length; i++){
+ if(i % 2 == 0){
+ oddsum += Number(code[i]);
+ }else{
+ evensum += Number(code[i]);
+ }
+ }
+ total = oddsum * 3 + evensum;
+ return Number((10 - total % 10) % 10);
+ },
+
+ // returns true if the ean is a valid EAN barcode number by checking the control digit.
+ // ean must be a string
+ check_ean: function(ean){
+ return /^\d+$/.test(ean) && this.ean_checksum(ean) === Number(ean[ean.length-1]);
+ },
+
+ // returns a valid zero padded ean13 from an ean prefix. the ean prefix must be a string.
+ sanitize_ean: function(ean){
+ ean = ean.substr(0,13);
+
+ for(var n = 0, count = (13 - ean.length); n < count; n++){
+ ean = ean + '0';
+ }
+ return ean.substr(0,12) + this.ean_checksum(ean);
+ },
+
+ // attempts to interpret a barcode (string encoding a barcode Code-128)
+ // it will return an object containing various information about the barcode.
+ // most importantly :
+ // - code : the barcode
+ // - type : the type of the barcode (e.g. alias, unit product, weighted product...)
+ //
+ // - value : if the barcode encodes a numerical value, it will be put there
+ // - base_code : the barcode with all the encoding parts set to zero; the one put on
+ // the product in the backend
+ parse_barcode: function(barcode){
+ var self = this;
+ var parsed_result = {
+ encoding: '',
+ type:'error',
+ code:barcode,
+ base_code: barcode,
+ value: 0,
+ };
+ if (!self.nomenclature) {
+ return parsed_result;
+ }
+
+ // Checks if barcode matches the pattern
+ // Additionnaly retrieves the optional numerical content in barcode
+ // Returns an object containing:
+ // - value: the numerical value encoded in the barcode (0 if no value encoded)
+ // - base_code: the barcode in which numerical content is replaced by 0's
+ // - match: boolean
+ function match_pattern(barcode, pattern){
+ var match = {
+ value: 0,
+ base_code: barcode,
+ match: false,
+ };
+ barcode = barcode.replace("\\", "\\\\").replace("{", '\{').replace("}", "\}").replace(".", "\.");
+
+ var numerical_content = pattern.match(/[{][N]*[D]*[}]/); // look for numerical content in pattern
+ var base_pattern = pattern;
+ if(numerical_content){ // the pattern encodes a numerical content
+ var num_start = numerical_content.index; // start index of numerical content
+ var num_length = numerical_content[0].length; // length of numerical content
+ var value_string = barcode.substr(num_start, num_length-2); // numerical content in barcode
+ var whole_part_match = numerical_content[0].match("[{][N]*[D}]"); // looks for whole part of numerical content
+ var decimal_part_match = numerical_content[0].match("[{N][D]*[}]"); // looks for decimal part
+ var whole_part = value_string.substr(0, whole_part_match.index+whole_part_match[0].length-2); // retrieve whole part of numerical content in barcode
+ var decimal_part = "0." + value_string.substr(decimal_part_match.index, decimal_part_match[0].length-1); // retrieve decimal part
+ if (whole_part === ''){
+ whole_part = '0';
+ }
+ match['value'] = parseInt(whole_part) + parseFloat(decimal_part);
+
+ // replace numerical content by 0's in barcode and pattern
+ match['base_code'] = barcode.substr(0,num_start);
+ var base_pattern = pattern.substr(0,num_start);
+ for(var i=0;i<(num_length-2);i++) {
+ match['base_code'] += "0";
+ base_pattern += "0";
+ }
+ match['base_code'] += barcode.substr(num_start+num_length-2,barcode.length-1);
+ base_pattern += pattern.substr(num_start+num_length,pattern.length-1);
+
+ match['base_code'] = match['base_code'].replace("\\\\", "\\").replace("\{", "{").replace("\}","}").replace("\.",".");
+ }
+
+ match['match'] = match['base_code'].substr(0,base_pattern.length).match(base_pattern);
+ return match;
+ }
+
+ // If the nomenclature does not use strict EAN, prepend the barcode with a 0 if it seems
+ // that it has been striped by the barcode scanner, when trying to match an EAN13 rule
+ var prepend_zero = false;
+ if(!self.strict_ean && barcode.length === 12 && self.check_ean("0"+barcode)){
+ prepend_zero = true;
+ }
+ var rules = self.nomenclature.rules;
+ for (var i = 0; i < rules.length; i++) {
+ var cur_barcode = barcode;
+ if (prepend_zero && rules[i].encoding == 'ean13'){
+ cur_barcode = '0'+cur_barcode;
+ }
+ var match = match_pattern(cur_barcode,rules[i].pattern);
+ if (match.match) {
+ if(rules[i].type === 'alias') {
+ barcode = rules[i].alias;
+ parsed_result.code = barcode;
+ parsed_result.type = 'alias';
+ }
+ else {
+ parsed_result.encoding = rules[i].encoding;
+ parsed_result.type = rules[i].type;
+ parsed_result.value = match.value
+ parsed_result.code = cur_barcode;
+ if (rules[i].encoding === "ean13"){
+ parsed_result.base_code = this.sanitize_ean(match.base_code);
+ }
+ else{
+ parsed_result.base_code = match.base_code;
+ }
+ return parsed_result;
+ }
+ }
+ }
+ return parsed_result;
+ },
+ });
+}
diff --git a/addons/barcodes/views/templates.xml b/addons/barcodes/views/templates.xml
new file mode 100644
index 00000000000..9e255e57290
--- /dev/null
+++ b/addons/barcodes/views/templates.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/point_of_sale/__openerp__.py b/addons/point_of_sale/__openerp__.py
index ccf472c72a3..40a98b1888c 100644
--- a/addons/point_of_sale/__openerp__.py
+++ b/addons/point_of_sale/__openerp__.py
@@ -48,9 +48,10 @@ Main Features
""",
'author': 'OpenERP SA',
'images': ['images/pos_touch_screen.jpeg', 'images/pos_session.jpeg', 'images/pos_analysis.jpeg','images/sale_order_pos.jpeg','images/product_pos.jpeg'],
- 'depends': ['sale_stock'],
+ 'depends': ['sale_stock', 'barcodes'],
'data': [
'data/report_paperformat.xml',
+ 'data/default_barcode_patterns.xml',
'security/point_of_sale_security.xml',
'security/ir.model.access.csv',
'wizard/pos_box.xml',
diff --git a/addons/point_of_sale/data/default_barcode_patterns.xml b/addons/point_of_sale/data/default_barcode_patterns.xml
new file mode 100644
index 00000000000..1efcf941597
--- /dev/null
+++ b/addons/point_of_sale/data/default_barcode_patterns.xml
@@ -0,0 +1,40 @@
+
+
+
+
+ Cashier Barcodes
+
+ 50
+ cashier
+ any
+ 041
+
+
+
+ Customer Barcodes
+
+ 40
+ client
+ any
+ 042
+
+
+
+ Discount Barcodes
+
+ 20
+ discount
+ any
+ 22{NN}
+
+
+
+ Price Barcodes 2 Decimals
+
+ 14
+ price
+ ean13
+ 23.....{NNNDD}
+
+
+
diff --git a/addons/point_of_sale/point_of_sale.py b/addons/point_of_sale/point_of_sale.py
index 5b67bdc9c1c..d30d754ff24 100644
--- a/addons/point_of_sale/point_of_sale.py
+++ b/addons/point_of_sale/point_of_sale.py
@@ -21,8 +21,10 @@
import logging
import time
+import uuid
+import sets
-from openerp import tools
+from openerp import tools, models
from openerp.osv import fields, osv
from openerp.tools.translate import _
from openerp.exceptions import Warning
@@ -63,8 +65,6 @@ class pos_config(osv.osv):
domain=[('type', '=', 'sale')],
help="Accounting journal used to post sales entries."),
'currency_id' : fields.function(_get_currency, type="many2one", string="Currency", relation="res.currency"),
- 'iface_self_checkout' : fields.boolean('Self Checkout Mode', # FIXME : this field is obsolete
- help="Check this if this point of sale should open by default in a self checkout mode. If unchecked, Odoo uses the normal cashier mode by default."),
'iface_cashdrawer' : fields.boolean('Cashdrawer', help="Automatically open the cashdrawer"),
'iface_payment_terminal' : fields.boolean('Payment Terminal', help="Enables Payment Terminal integration"),
'iface_electronic_scale' : fields.boolean('Electronic Scale', help="Enables Electronic Scale integration"),
@@ -74,11 +74,15 @@ class pos_config(osv.osv):
'iface_invoicing': fields.boolean('Invoicing',help='Enables invoice generation from the Point of Sale'),
'iface_big_scrollbars': fields.boolean('Large Scrollbars',help='For imprecise industrial touchscreens'),
'iface_fullscreen': fields.boolean('Fullscreen', help='Display the Point of Sale in full screen mode'),
+ 'iface_print_auto': fields.boolean('Automatic Receipt Printing', help='The receipt will automatically be printed at the end of each order'),
+ 'iface_precompute_cash': fields.boolean('Prefill Cash Payment', help='The payment input will behave similarily to bank payment input, and will be prefilled with the exact due amount'),
+ 'iface_tax_included': fields.boolean('Include Taxes in Prices', help='The displayed prices will always include all taxes, even if the taxes have been setup differently'),
'receipt_header': fields.text('Receipt Header',help="A short text that will be inserted as a header in the printed receipt"),
'receipt_footer': fields.text('Receipt Footer',help="A short text that will be inserted as a footer in the printed receipt"),
'proxy_ip': fields.char('IP Address', help='The hostname or ip address of the hardware proxy, Will be autodetected if left empty', size=45),
'state' : fields.selection(POS_CONFIG_STATE, 'Status', required=True, readonly=True, copy=False),
+ 'uuid' : fields.char('uuid', readonly=True, help='A globally unique identifier for this pos configuration, used to prevent conflicts in client-generated data'),
'sequence_id' : fields.many2one('ir.sequence', 'Order IDs Sequence', readonly=True,
help="This sequence is automatically created by Odoo but you can change it "\
"to customize the reference numbers of your orders.", copy=False),
@@ -86,12 +90,9 @@ class pos_config(osv.osv):
'group_by' : fields.boolean('Group Journal Items', help="Check this if you want to group the Journal Items by Product while closing a Session"),
'pricelist_id': fields.many2one('product.pricelist','Pricelist', required=True),
'company_id': fields.many2one('res.company', 'Company', required=True),
- 'barcode_product': fields.char('Product Barcodes', size=64, help='The pattern that identifies product barcodes'),
- 'barcode_cashier': fields.char('Cashier Barcodes', size=64, help='The pattern that identifies cashier login barcodes'),
- 'barcode_customer': fields.char('Customer Barcodes',size=64, help='The pattern that identifies customer\'s client card barcodes'),
- 'barcode_price': fields.char('Price Barcodes', size=64, help='The pattern that identifies a product with a barcode encoded price'),
- 'barcode_weight': fields.char('Weight Barcodes', size=64, help='The pattern that identifies a product with a barcode encoded weight'),
- 'barcode_discount': fields.char('Discount Barcodes', size=64, help='The pattern that identifies a product with a barcode encoded discount'),
+ 'barcode_nomenclature_id': fields.many2one('barcode.nomenclature','Barcodes', help='Defines what kind of barcodes are available and how they are assigned to products, customers and cashiers', required=True),
+ 'group_pos_manager_id': fields.many2one('res.groups','Point of Sale Manager Group', help='This field is there to pass the id of the pos manager group to the point of sale client'),
+ 'group_pos_user_id': fields.many2one('res.groups','Point of Sale User Group', help='This field is there to pass the id of the pos user group to the point of sale client'),
}
def _check_cash_control(self, cr, uid, ids, context=None):
@@ -164,22 +165,41 @@ class pos_config(osv.osv):
def _get_default_company(self, cr, uid, context=None):
company_id = self.pool.get('res.users')._get_company(cr, uid, context=context)
+ print company_id
return company_id
+ def _get_default_nomenclature(self, cr, uid, context=None):
+ nom_obj = self.pool.get('barcode.nomenclature')
+ res = nom_obj.search(cr, uid, [], limit=1, context=context)
+ return res and res[0] or False
+
+ def _get_group_pos_manager(self, cr, uid, context=None):
+ group = self.pool.get('ir.model.data').get_object_reference(cr,uid,'point_of_sale','group_pos_manager')
+ if group:
+ return group[1]
+ else:
+ return False
+
+ def _get_group_pos_user(self, cr, uid, context=None):
+ group = self.pool.get('ir.model.data').get_object_reference(cr,uid,'point_of_sale','group_pos_user')
+ if group:
+ return group[1]
+ else:
+ return False
+
_defaults = {
+ 'uuid' : lambda self, cr, uid, context={}: str(uuid.uuid4()),
'state' : POS_CONFIG_STATE[0][0],
'journal_id': _default_sale_journal,
'group_by' : True,
'pricelist_id': _default_pricelist,
'iface_invoicing': True,
+ 'iface_print_auto': True,
'stock_location_id': _get_default_location,
'company_id': _get_default_company,
- 'barcode_product': '*',
- 'barcode_cashier': '041*',
- 'barcode_customer':'042*',
- 'barcode_weight': '21xxxxxNNDDD',
- 'barcode_discount':'22xxxxxxxxNN',
- 'barcode_price': '23xxxxxNNNDD',
+ 'barcode_nomenclature_id': _get_default_nomenclature,
+ 'group_pos_manager_id': _get_group_pos_manager,
+ 'group_pos_user_id': _get_group_pos_user,
}
def onchange_picking_type_id(self, cr, uid, ids, picking_type_id, context=None):
@@ -1338,7 +1358,7 @@ class ean_wizard(osv.osv_memory):
ean13 = openerp.addons.product.product.sanitize_ean13(r.ean13_pattern)
m = context.get('active_model')
m_id = context.get('active_id')
- self.pool[m].write(cr,uid,[m_id],{'ean13':ean13})
+ self.pool[m].write(cr,uid,[m_id],{'barcode':ean13})
return { 'type' : 'ir.actions.act_window_close' }
class pos_category(osv.osv):
@@ -1443,6 +1463,18 @@ class res_partner(osv.osv):
return partner_id
+class barcode_rule(models.Model):
+ _inherit = 'barcode.rule'
+ def _get_type_selection(self):
+ types = sets.Set(super(barcode_rule,self)._get_type_selection())
+ types.update([
+ ('weight','Weighted Product'),
+ ('price','Priced Product'),
+ ('discount','Discounted Product'),
+ ('client','Client'),
+ ('cashier','Cashier')
+ ])
+ return list(types)
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
diff --git a/addons/point_of_sale/point_of_sale_data.xml b/addons/point_of_sale/point_of_sale_data.xml
index 1013c3ce3ca..8124f69d5a7 100644
--- a/addons/point_of_sale/point_of_sale_data.xml
+++ b/addons/point_of_sale/point_of_sale_data.xml
@@ -30,6 +30,7 @@
Main
+
diff --git a/addons/point_of_sale/point_of_sale_demo.xml b/addons/point_of_sale/point_of_sale_demo.xml
index ea6d62449f8..aa17e053e6d 100644
--- a/addons/point_of_sale/point_of_sale_demo.xml
+++ b/addons/point_of_sale/point_of_sale_demo.xml
@@ -1,37 +1,37 @@
-
- 0420100000005
- 0420200000004
- 0420300000003
- 0420400000002
- 0420500000001
- 0420600000000
- 0420700000009
- 0420800000008
- 0420900000007
- 0421000000003
- 0421100000002
- 0421200000001
- 0421300000000
- 0421400000009
- 0421500000008
- 0421600000007
- 0421700000006
- 0421800000005
- 0421900000004
- 0422000000000
- 0422100000009
+
+ 0420100000005
+ 0420200000004
+ 0420300000003
+ 0420400000002
+ 0420500000001
+ 0420600000000
+ 0420700000009
+ 0420800000008
+ 0420900000007
+ 0421000000003
+ 0421100000002
+ 0421200000001
+ 0421300000000
+ 0421400000009
+ 0421500000008
+ 0421600000007
+ 0421700000006
+ 0421800000005
+ 0421900000004
+ 0422000000000
+ 0422100000009
- 0410100000006
+ 0410100000006
- 0410100000006
+ 0410100000006
@@ -191,7 +191,7 @@
1.98Boni OrangesTrue
- 2100002000003
+ 2100002000003
@@ -212,7 +212,7 @@
True1.98Lemon
- 2301000000006
+ 2301000000006True
@@ -297,7 +297,7 @@
5.10PeachesTrue
- 2300001000008
+ 2300001000008
@@ -373,7 +373,7 @@
1.28OnionsTrue
- 2100001000004
+ 2100001000004
@@ -435,7 +435,7 @@
True1.49Coca-Cola Regular 1L
- 5449000054227
+ 5449000054227
@@ -457,7 +457,7 @@
True0.51Coca-Cola Regular 33cl
- 5449000000996
+ 5449000000996
@@ -722,7 +722,7 @@
True0.44
- 5449000111715
+ 5449000111715Chaudfontaine 50cl
@@ -837,7 +837,7 @@
True0.77
- 5410228142027
+ 5410228142027Jupiler 33cl
@@ -896,7 +896,7 @@
True0.83Belle-Vue Kriek 25cl
- 5410228193449
+ 5410228193449
@@ -905,7 +905,7 @@
True1.00Leffe Brune 33cl
- 5410228142164
+ 5410228142164
@@ -913,7 +913,7 @@
True1.00Leffe Blonde 33cl
- 5410228142218
+ 5410228142218
diff --git a/addons/point_of_sale/point_of_sale_view.xml b/addons/point_of_sale/point_of_sale_view.xml
index d1edbdf43cc..8e6ea1df174 100644
--- a/addons/point_of_sale/point_of_sale_view.xml
+++ b/addons/point_of_sale/point_of_sale_view.xml
@@ -553,7 +553,7 @@
product.template
-
+
@@ -564,11 +564,23 @@
product.product
-
+
+
+
+ product.template.tree.inherit
+ product.template
+
+
+
+
+
+
+
+
+
@@ -685,50 +698,25 @@
+
+
+
-
+
-
-
+
+
-
-
-
-
-
- Barcode Patterns allow to match barcodes to actions or to embed information such as price and quantity in the barcode.
- Barcode Patterns only work with EAN13 barcodes.
-
-
- Each type of barcode accepts a list of patterns seprated by commas. A scanned
- barcode will be attributed to a type if it matches one of its patterns.
- The patterns take the form of EAN13 barcodes. Numbers in the pattern must match
- the number in the scanned barcode. A 'x' or a '*' in a pattern will match
- any one number. If the patterns are shorter than EAN13 barcodes, they are assumed
- to be prefixes and match at the beginning. Weight, Price and Discount patterns also
- tell how the weight, price or discount is encoded in the barcode. 'N' indicate the
- positions where the integer part is en encoded, and 'D' where the decimals are encoded.
- If multiple pattern match one barcode, the longest pattern with the less 'x' or '*' is
- considered the matching one. If a barcode matches no pattern it will not be found in
- the POS.
-
diff --git a/addons/point_of_sale/views/point_of_sale.xml b/addons/point_of_sale/views/point_of_sale.xml
index 9e52ffe582b..2146a6dbb35 100644
--- a/addons/point_of_sale/views/point_of_sale.xml
+++ b/addons/point_of_sale/views/point_of_sale.xml
@@ -7,6 +7,7 @@
+
@@ -15,7 +16,6 @@
-
diff --git a/addons/point_of_sale/views/templates.xml b/addons/point_of_sale/views/templates.xml
index 9a7ee7f1bf1..a9b4e7a2011 100644
--- a/addons/point_of_sale/views/templates.xml
+++ b/addons/point_of_sale/views/templates.xml
@@ -26,7 +26,7 @@
-
+
diff --git a/addons/pos_discount/static/src/js/discount.js b/addons/pos_discount/static/src/js/discount.js
index 5eaab362d20..d68eb59f8bf 100644
--- a/addons/pos_discount/static/src/js/discount.js
+++ b/addons/pos_discount/static/src/js/discount.js
@@ -17,11 +17,11 @@ openerp.pos_discount = function(instance){
var discount = $(QWeb.render('DiscountButton'));
discount.click(function(){
- var order = self.pos.get('selectedOrder');
+ var order = self.pos.get_order();
var product = self.pos.db.get_product_by_id(self.pos.config.discount_product_id[0]);
- var discount = - self.pos.config.discount_pc/ 100.0 * order.getTotalTaxIncluded();
+ var discount = - self.pos.config.discount_pc/ 100.0 * order.get_total_tax_included();
if( discount < 0 ){
- order.addProduct(product, { price: discount });
+ order.add_product(product, { price: discount });
}
});
diff --git a/addons/pos_loyalty/__init__.py b/addons/pos_loyalty/__init__.py
new file mode 100644
index 00000000000..ec876f764af
--- /dev/null
+++ b/addons/pos_loyalty/__init__.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Management Solution
+# Copyright (C) 2004-2010 Tiny SPRL ().
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+##############################################################################
+
+import loyalty
+
+# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
+
diff --git a/addons/pos_loyalty/__openerp__.py b/addons/pos_loyalty/__openerp__.py
new file mode 100644
index 00000000000..db12d334c10
--- /dev/null
+++ b/addons/pos_loyalty/__openerp__.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Management Solution
+# Copyright (C) 2004-2010 Tiny SPRL ().
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+##############################################################################
+
+
+{
+ 'name': 'Loyalty Program',
+ 'version': '1.0',
+ 'category': 'Point of Sale',
+ 'sequence': 6,
+ 'summary': 'Loyalty Program for the Point of Sale ',
+ 'description': """
+
+=======================
+
+This module allows you to define a loyalty program in
+the point of sale, where the customers earn loyalty points
+and get rewards.
+
+""",
+ 'author': 'OpenERP SA',
+ 'depends': ['point_of_sale'],
+ 'data': [
+ 'views/views.xml',
+ 'security/ir.model.access.csv',
+ 'views/templates.xml'
+ ],
+ 'qweb': ['static/src/xml/loyalty.xml'],
+ 'installable': True,
+ 'auto_install': False,
+}
+
+# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
diff --git a/addons/pos_loyalty/loyalty.py b/addons/pos_loyalty/loyalty.py
new file mode 100644
index 00000000000..8e8b240c74e
--- /dev/null
+++ b/addons/pos_loyalty/loyalty.py
@@ -0,0 +1,145 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Management Solution
+# Copyright (C) 2004-2010 Tiny SPRL ().
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+##############################################################################
+
+import logging
+
+import openerp
+
+from openerp import tools
+from openerp.osv import fields, osv
+from openerp.tools.translate import _
+
+_logger = logging.getLogger(__name__)
+
+class loyalty_program(osv.osv):
+ _name = 'loyalty.program'
+ _columns = {
+ 'name' : fields.char('Loyalty Program Name', size=32, select=1,
+ required=True, help="An internal identification for the loyalty program configuration"),
+ 'pp_currency': fields.float('Points per currency',help="How many loyalty points are given to the customer by sold currency"),
+ 'pp_product': fields.float('Points per product',help="How many loyalty points are given to the customer by product sold"),
+ 'pp_order': fields.float('Points per order',help="How many loyalty points are given to the customer for each sale or order"),
+ 'rounding': fields.float('Points Rounding', help="The loyalty point amounts are rounded to multiples of this value."),
+ 'rule_ids': fields.one2many('loyalty.rule','loyalty_program_id','Rules'),
+ 'reward_ids': fields.one2many('loyalty.reward','loyalty_program_id','Rewards'),
+
+ }
+ _defaults = {
+ 'rounding': 1,
+ }
+
+class loyalty_rule(osv.osv):
+ _name = 'loyalty.rule'
+ _columns = {
+ 'name': fields.char('Name', size=32, select=1, required=True, help="An internal identification for this loyalty program rule"),
+ 'loyalty_program_id': fields.many2one('loyalty.program', 'Loyalty Program', help='The Loyalty Program this exception belongs to'),
+ 'type': fields.selection((('product','Product'),('category','Category')), 'Type', required=True, help='Does this rule affects products, or a category of products ?'),
+ 'product_id': fields.many2one('product.product','Target Product', help='The product affected by the rule'),
+ 'category_id': fields.many2one('pos.category', 'Target Category', help='The category affected by the rule'),
+ 'cumulative': fields.boolean('Cumulative', help='The points won from this rule will be won in addition to other rules'),
+ 'pp_product': fields.float('Points per product', help='How many points the product will earn per product ordered'),
+ 'pp_currency': fields.float('Points per currency', help='How many points the product will earn per value sold'),
+ }
+ _defaults = {
+ 'type':'product',
+ }
+
+
+class loyalty_reward(osv.osv):
+ _name = 'loyalty.reward'
+ _columns = {
+ 'name': fields.char('Name', size=32, select=1, required=True, help='An internal identification for this loyalty reward'),
+ 'loyalty_program_id': fields.many2one('loyalty.program', 'Loyalty Program', help='The Loyalty Program this reward belongs to'),
+ 'minimum_points': fields.float('Minimum Points', help='The minimum amount of points the customer must have to qualify for this reward'),
+ 'type': fields.selection((('gift','Gift'),('discount','Discount'),('resale','Resale')), 'Type', required=True, help='The type of the reward'),
+ 'gift_product_id': fields.many2one('product.product','Gift Product', help='The product given as a reward'),
+ 'point_cost': fields.float('Point Cost', help='The cost of the reward'),
+ 'discount_product_id': fields.many2one('product.product','Discount Product', help='The product used to apply discounts'),
+ 'discount': fields.float('Discount',help='The discount percentage'),
+ 'point_product_id': fields.many2one('product.product', 'Point Product', help='The product that represents a point that is sold by the customer'),
+ }
+
+ def _check_gift_product(self, cr, uid, ids, context=None):
+ for reward in self.browse(cr, uid, ids, context=context):
+ if reward.type == 'gift':
+ return bool(reward.gift_product_id)
+ else:
+ return True
+
+ def _check_discount_product(self, cr, uid, ids, context=None):
+ for reward in self.browse(cr, uid, ids, context=context):
+ if reward.type == 'discount':
+ return bool(reward.discount_product_id)
+ else:
+ return True
+
+ def _check_point_product(self, cr, uid, ids, context=None):
+ for reward in self.browse(cr, uid, ids, context=context):
+ if reward.type == 'resale':
+ return bool(reward.point_product_id)
+ else:
+ return True
+
+ _constraints = [
+ (_check_gift_product, "The gift product field is mandatory for gift rewards", ["type","gift_product_id"]),
+ (_check_discount_product, "The discount product field is mandatory for discount rewards", ["type","discount_product_id"]),
+ (_check_point_product, "The point product field is mandatory for point resale rewards", ["type","discount_product_id"]),
+ ]
+
+class pos_config(osv.osv):
+ _inherit = 'pos.config'
+ _columns = {
+ 'loyalty_id': fields.many2one('loyalty.program','Loyalty Program', help='The loyalty program used by this point_of_sale'),
+
+ }
+
+class res_partner(osv.osv):
+ _inherit = 'res.partner'
+ _columns = {
+ 'loyalty_points': fields.float('Loyalty Points', help='The loyalty points the user won as part of a Loyalty Program')
+ }
+
+class pos_order(osv.osv):
+ _inherit = 'pos.order'
+
+ _columns = {
+ 'loyalty_points': fields.float('Loyalty Points', help='The amount of Loyalty points the customer won or lost with this order'),
+ }
+
+ def _order_fields(self, cr, uid, ui_order, context=None):
+ fields = super(pos_order,self)._order_fields(cr,uid,ui_order,context)
+ fields['loyalty_points'] = ui_order.get('loyalty_points',0)
+ return fields
+
+ def create_from_ui(self, cr, uid, orders, context=None):
+ ids = super(pos_order,self).create_from_ui(cr,uid,orders,context=context)
+ for order in orders:
+ if order['data']['loyalty_points'] != 0 and order['data']['partner_id']:
+ partner = self.pool.get('res.partner').browse(cr,uid,order['data']['partner_id'], context=context)
+ partner.write({'loyalty_points': partner['loyalty_points'] + order['data']['loyalty_points']})
+
+ return ids
+
+
+
+
+
+
diff --git a/addons/pos_loyalty/security/ir.model.access.csv b/addons/pos_loyalty/security/ir.model.access.csv
new file mode 100644
index 00000000000..8c13e017158
--- /dev/null
+++ b/addons/pos_loyalty/security/ir.model.access.csv
@@ -0,0 +1,7 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_loyalty_program,loyalty.program.user,model_loyalty_program,point_of_sale.group_pos_user,1,0,0,0
+access_loyalty_program_manager,loyalty.program.manager,model_loyalty_program,point_of_sale.group_pos_manager,1,1,1,1
+access_loyalty_rule,loyalty.rule.user,model_loyalty_rule,point_of_sale.group_pos_user,1,0,0,0
+access_loyalty_rule_manager,loyalty.rule.manager,model_loyalty_rule,point_of_sale.group_pos_manager,1,1,1,1
+access_loyalty_reward,loyalty.reward.user,model_loyalty_reward,point_of_sale.group_pos_user,1,0,0,0
+access_loyalty_reward_manager,loyalty.reward.manager,model_loyalty_reward,point_of_sale.group_pos_manager,1,1,1,1
diff --git a/addons/pos_loyalty/static/src/css/loyalty.css b/addons/pos_loyalty/static/src/css/loyalty.css
new file mode 100644
index 00000000000..4d141918617
--- /dev/null
+++ b/addons/pos_loyalty/static/src/css/loyalty.css
@@ -0,0 +1,21 @@
+
+.pos .order .summary .loyalty-points{
+ margin-left: 20px;
+ float: left;
+ padding: 10px;
+ max-width: 216px;
+ text-align: left;
+ color: #6EC89B;
+ background: rgba(110, 200, 155, 0.17);
+ border-radius: 3px;
+}
+.pos .order .summary .loyalty-points.negative{
+ color: #C86E6E;
+ background: rgba(200, 110, 110, 0.17);
+}
+.pos .order .summary .loyalty-points-total {
+ border-top: solid 2px;
+ text-align: center;
+ padding-top: 4px;
+ margin-top: 4px;
+}
diff --git a/addons/pos_loyalty/static/src/js/loyalty.js b/addons/pos_loyalty/static/src/js/loyalty.js
new file mode 100644
index 00000000000..9e92653e35a
--- /dev/null
+++ b/addons/pos_loyalty/static/src/js/loyalty.js
@@ -0,0 +1,435 @@
+openerp.pos_loyalty = function(instance){
+
+ var module = instance.point_of_sale;
+ var round_pr = instance.web.round_precision
+ var QWeb = instance.web.qweb;
+
+ var models = module.PosModel.prototype.models;
+ for (var i = 0; i < models.length; i++) {
+ var model = models[i];
+ if (model.model === 'res.partner') {
+ model.fields.push('loyalty_points');
+ } else if (model.model === 'product.product') {
+ // load loyalty after products
+ models.push(i+1,0,{
+ model: 'loyalty.program',
+ condition: function(self){ return !!self.config.loyalty_id[0]; },
+ fields: ['name','pp_currency','pp_product','pp_order','rounding'],
+ domain: function(self){ return [['id','=',self.config.loyalty_id[0]]]; },
+ loaded: function(self,loyalties){ self.loyalty = loyalties[0]; },
+ },{
+ model: 'loyalty.rule',
+ condition: function(self){ return !!self.loyalty; },
+ fields: ['name','type','product_id','category_id','cumulative','pp_product','pp_currency'],
+ domain: function(self){ return [['loyalty_program_id','=',self.loyalty.id]]; },
+ loaded: function(self,rules){
+
+ self.loyalty.rules = rules;
+ self.loyalty.rules_by_product_id = {};
+ self.loyalty.rules_by_category_id = {};
+
+ for (var i = 0; i < rules.length; i++){
+ var rule = rules[i];
+ if (rule.type === 'product') {
+ if (!self.loyalty.rules_by_product_id[rule.product_id[0]]) {
+ self.loyalty.rules_by_product_id[rule.product_id[0]] = [rule];
+ } else if (rule.cumulative) {
+ self.loyalty.rules_by_product_id[rule.product_id[0]].unshift(rule);
+ } else {
+ self.loyalty.rules_by_product_id[rule.product_id[0]].push(rule);
+ }
+ } else if (rule.type === 'category') {
+ var category = self.db.get_category_by_id(rule.category_id[0]);
+ if (!self.loyalty.rules_by_category_id[category.id]) {
+ self.loyalty.rules_by_category_id[category.id] = [rule];
+ } else if (rule.cumulative) {
+ self.loyalty.rules_by_category_id[category.id].unshift(rule);
+ } else {
+ self.loyalty.rules_by_category_id[category.id].push(rule);
+ }
+ }
+ }
+ },
+ },{
+ model: 'loyalty.reward',
+ condition: function(self){ return !!self.loyalty; },
+ fields: ['name','type','minimum_points','gift_product_id','point_cost','discount_product_id','discount','point_value','point_product_id'],
+ domain: function(self){ return [['loyalty_program_id','=',self.loyalty.id]]; },
+ loaded: function(self,rewards){
+ self.loyalty.rewards = rewards;
+ self.loyalty.rewards_by_id = {};
+ for (var i = 0; i < rewards.length;i++) {
+ self.loyalty.rewards_by_id[rewards[i].id] = rewards[i];
+ }
+ },
+ });
+ }
+ }
+
+ var _super_orderline = module.Orderline;
+ module.Orderline = module.Orderline.extend({
+ get_reward: function(){
+ return this.pos.loyalty.rewards_by_id[this.reward_id];
+ },
+ set_reward: function(reward){
+ this.reward_id = reward.id;
+ },
+ export_as_JSON: function(){
+ var json = _super_orderline.prototype.export_as_JSON.apply(this,arguments);
+ json.reward_id = this.reward_id;
+ return json;
+ },
+ init_from_JSON: function(json){
+ _super_orderline.prototype.init_from_JSON.apply(this,arguments);
+ this.reward_id = json.reward_id;
+ },
+ });
+
+ var _super = module.Order;
+ module.Order = module.Order.extend({
+
+ /* The total of points won, excluding the points spent on rewards */
+ get_won_points: function(){
+ if (!this.pos.loyalty || !this.get_client()) {
+ return 0;
+ }
+
+ var orderLines = this.get_orderlines();
+ var rounding = this.pos.loyalty.rounding;
+
+ var product_sold = 0;
+ var total_sold = 0;
+ var total_points = 0;
+
+ for (var i = 0; i < orderLines.length; i++) {
+ var line = orderLines[i];
+ var product = line.get_product();
+ var rules = this.pos.loyalty.rules_by_product_id[product.id] || [];
+ var overriden = false;
+
+ if (line.get_reward()) { // Reward products are ignored
+ continue;
+ }
+
+ for (var j = 0; j < rules.length; j++) {
+ var rule = rules[j];
+ total_points += round_pr(line.get_quantity() * rule.pp_product, rounding);
+ total_points += round_pr(line.get_price_with_tax() * rule.pp_currency, rounding);
+ // if affected by a non cumulative rule, skip the others. (non cumulative rules are put
+ // at the beginning of the list when they are loaded )
+ if (!rule.cumulative) {
+ overriden = true;
+ break;
+ }
+ }
+
+ // Test the category rules
+ if ( product.pos_categ_id ) {
+ var category = this.pos.db.get_category_by_id(product.pos_categ_id[0]);
+ while (category && !overriden) {
+ var rules = this.pos.loyalty.rules_by_category_id[category.id] || [];
+ for (var j = 0; j < rules.length; j++) {
+ var rule = rules[j];
+ total_points += round_pr(line.get_quantity() * rule.pp_product, rounding);
+ total_points += round_pr(line.get_price_with_tax() * rule.pp_currency, rounding);
+ if (!rule.cumulative) {
+ overriden = true;
+ break;
+ }
+ }
+ var _category = category;
+ category = this.pos.db.get_category_by_id(this.pos.db.get_category_parent_id(category.id));
+ if (_category === category) {
+ break;
+ }
+ }
+ }
+
+ if (!overriden) {
+ product_sold += line.get_quantity();
+ total_sold += line.get_price_with_tax();
+ }
+ }
+
+ total_points += round_pr( total_sold * this.pos.loyalty.pp_currency, rounding );
+ total_points += round_pr( product_sold * this.pos.loyalty.pp_product, rounding );
+ total_points += round_pr( this.pos.loyalty.pp_order, rounding );
+
+ return total_points;
+ },
+
+ /* The total number of points spent on rewards */
+ get_spent_points: function() {
+ if (!this.pos.loyalty || !this.get_client()) {
+ return 0;
+ } else {
+ var lines = this.get_orderlines();
+ var rounding = this.pos.loyalty.rounding;
+ var points = 0;
+
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i];
+ var reward = line.get_reward();
+ if (reward) {
+ if (reward.type === 'gift') {
+ points += round_pr(line.get_quantity() * reward.point_cost, rounding);
+ } else if (reward.type === 'discount') {
+ points += round_pr(-line.get_display_price() * reward.point_cost, rounding);
+ } else if (reward.type === 'resale') {
+ points += (-line.get_quantity());
+ }
+ }
+ }
+
+ return points;
+ }
+ },
+
+ /* The total number of points lost or won after the order is validated */
+ get_new_points: function() {
+ if (!this.pos.loyalty || !this.get_client()) {
+ return 0;
+ } else {
+ return round_pr(this.get_won_points() - this.get_spent_points(), this.pos.loyalty.rounding);
+ }
+ },
+
+ /* The total number of points that the customer will have after this order is validated */
+ get_new_total_points: function() {
+ if (!this.pos.loyalty || !this.get_client()) {
+ return 0;
+ } else {
+ return round_pr(this.get_client().loyalty_points + this.get_new_points(), this.pos.loyalty.rounding);
+ }
+ },
+
+ /* The number of loyalty points currently owned by the customer */
+ get_current_points: function(){
+ return this.get_client() ? this.get_client().loyalty_points : 0;
+ },
+
+ /* The total number of points spendable on rewards */
+ get_spendable_points: function(){
+ if (!this.pos.loyalty || !this.get_client()) {
+ return 0;
+ } else {
+ return round_pr(this.get_client().loyalty_points - this.get_spent_points(), this.pos.loyalty.rounding);
+ }
+ },
+
+ /* The list of rewards that the current customer can get */
+ get_available_rewards: function(){
+ var client = this.get_client();
+ if (!client) {
+ return [];
+ }
+
+ var rewards = [];
+ for (var i = 0; i < this.pos.loyalty.rewards.length; i++) {
+ var reward = this.pos.loyalty.rewards[i];
+ if (reward.minimum_points > this.get_spendable_points()) {
+ continue;
+ } else if(reward.type === 'gift' && reward.point_cost > this.get_spendable_points()) {
+ continue;
+ }
+ rewards.push(reward);
+ }
+ return rewards;
+ },
+
+ apply_reward: function(reward){
+ var client = this.get_client();
+ if (!client) {
+ return;
+ } else if (reward.type === 'gift') {
+ var product = this.pos.db.get_product_by_id(reward.gift_product_id[0]);
+ if (!product) {
+ this.pos.pos_widget.screen_selector.show_popup('error',{
+ 'message':'Configuration Error',
+ 'comment':'The product associated with the reward "'+reward.name+'" could not be found. Make sure it is available for sale in the point of sale.',
+ });
+ return;
+ }
+
+ var line = this.add_product(product, {
+ price: 0,
+ quantity: 1,
+ merge: false,
+ extras: { reward_id: reward.id },
+ });
+
+ } else if (reward.type === 'discount') {
+
+ var lrounding = this.pos.loyalty.rounding;
+ var crounding = this.pos.currency.rounding;
+ var spendable = this.get_spendable_points();
+ var order_total = this.get_total_with_tax();
+ var discount = round_pr(order_total * reward.discount,crounding);
+
+ if ( round_pr(discount * reward.point_cost,lrounding) > spendable ) {
+ discount = round_pr(Math.floor( spendable / reward.point_cost ), crounding);
+ }
+
+ var product = this.pos.db.get_product_by_id(reward.discount_product_id[0]);
+ if (!product) { //FIXME, move this as a server side constraint
+ this.pos.pos_widget.screen_selector.show_popup('error',{
+ 'message':'Configuration Error',
+ 'comment':'The product associated with the reward "'+reward.name+'" could not be found. Make sure it is available for sale in the point of sale.',
+ });
+ return;
+ }
+
+ var line = this.add_product(product, {
+ price: -discount,
+ quantity: 1,
+ merge: false,
+ extras: { reward_id: reward.id },
+ });
+
+ } else if (reward.type === 'resale') {
+
+ var lrounding = this.pos.loyalty.rounding;
+ var crounding = this.pos.currency.rounding;
+ var spendable = this.get_spendable_points();
+ var order_total = this.get_total_with_tax();
+ var product = this.pos.db.get_product_by_id(reward.point_product_id[0]);
+
+ if (!product) { //FIXME, move this as a server side constraint
+ this.pos.pos_widget.screen_selector.show_popup('error',{
+ 'message':'Configuration Error',
+ 'comment':'The product associated with the reward "'+reward.name+'" could not be found. Make sure it is available for sale in the point of sale.',
+ });
+ return;
+ }
+
+ if ( round_pr( spendable * product.price, crounding ) > order_total ) {
+ spendable = round_pr( Math.floor(order_total / product.price), lrounding);
+ }
+
+ if ( spendable < 0.00001 ) {
+ return;
+ }
+ var line = this.add_product(product, {
+ quantity: -spendable,
+ merge: false,
+ extras: { reward_id: reward.id },
+ });
+ }
+ },
+
+ validate: function(){
+ var client = this.get_client();
+ if ( client ) {
+ client.loyalty_points = this.get_new_total_points();
+ }
+ _super.prototype.validate.apply(this,arguments);
+ },
+ export_for_printing: function(){
+ var json = _super.prototype.export_for_printing.apply(this,arguments);
+ if (this.pos.loyalty && this.get_client()) {
+ json.loyalty = {
+ rounding: this.pos.loyalty.rounding || 1,
+ name: this.pos.loyalty.name,
+ client: this.get_client().name,
+ points_won : this.get_won_points(),
+ points_spent: this.get_spent_points(),
+ points_total: this.get_new_total_points(),
+ };
+ }
+ return json;
+ },
+ export_as_JSON: function(){
+ var json = _super.prototype.export_as_JSON.apply(this,arguments);
+ json.loyalty_points = this.get_new_points();
+ return json;
+ },
+ });
+
+ module.PosWidget.include({
+ loyalty_reward_click: function(){
+ var self = this;
+ var order = this.pos.get_order();
+ var client = order.get_client();
+ if (!client) {
+ this.screen_selector.set_current_screen('clientlist');
+ return;
+ }
+
+ var rewards = order.get_available_rewards();
+ if (rewards.length === 0) {
+ this.screen_selector.show_popup('error',{
+ 'message': 'No Rewards Available',
+ 'comment': 'There are no rewards available for this customer as part of the loyalty program',
+ });
+ return;
+ } else if (rewards.length === 1 && this.pos.loyalty.rewards.length === 1) {
+ order.apply_reward(rewards[0]);
+ return;
+ } else {
+ var list = [];
+ for (var i = 0; i < rewards.length; i++) {
+ list.push({
+ label: rewards[i].name,
+ item: rewards[i],
+ });
+ }
+ this.screen_selector.show_popup('selection',{
+ 'message': 'Please select a reward',
+ 'list': list,
+ 'confirm': function(reward){
+ order.apply_reward(reward);
+ },
+ });
+ }
+ },
+
+ build_widgets: function(){
+ var self = this;
+ this._super();
+
+ if(this.pos.loyalty && this.pos.loyalty.rewards.length ){
+ var button = $(QWeb.render('LoyaltyButton'));
+ button.click(function(){ self.loyalty_reward_click(); });
+ button.appendTo(this.$('.control-buttons'));
+ this.$('.control-buttons').removeClass('oe_hidden');
+ }
+
+ },
+ });
+
+ module.OrderWidget.include({
+ update_summary: function(){
+ this._super();
+
+ var order = this.pos.get_order();
+
+ var $loypoints = $(this.el).find('.summary .loyalty-points');
+
+ if(this.pos.loyalty && order.get_client()){
+ var points_won = order.get_won_points();
+ var points_spent = order.get_spent_points();
+ var points_total = order.get_new_total_points();
+ $loypoints.replaceWith($(QWeb.render('LoyaltyPoints',{
+ widget: this,
+ rounding: this.pos.loyalty.rounding,
+ points_won: points_won,
+ points_spent: points_spent,
+ points_total: points_total,
+ })));
+ $loypoints = $(this.el).find('.summary .loyalty-points');
+ $loypoints.removeClass('oe_hidden');
+
+ if(points_total < 0){
+ $loypoints.addClass('negative');
+ }else{
+ $loypoints.removeClass('negative');
+ }
+ }else{
+ $loypoints.empty();
+ $loypoints.addClass('oe_hidden');
+ }
+ },
+ });
+};
+
+
diff --git a/addons/pos_loyalty/static/src/xml/loyalty.xml b/addons/pos_loyalty/static/src/xml/loyalty.xml
new file mode 100644
index 00000000000..988096db61a
--- /dev/null
+++ b/addons/pos_loyalty/static/src/xml/loyalty.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
Points
+
+
+ +
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+ Rewards
+
+
+
+
+
+
.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
--------------------------------
+
+
+
+
+
+
+ Points Won
+
+
+ Points Spent
+
+
+ Total Points
+
+
+
+ Loyalty Programs allows you customer to earn points
+ and rewards when doing business at your shops.
+
+
+
+
+
+
+
+
diff --git a/addons/pos_restaurant/__openerp__.py b/addons/pos_restaurant/__openerp__.py
index edb5b0b64de..119efa45bbb 100644
--- a/addons/pos_restaurant/__openerp__.py
+++ b/addons/pos_restaurant/__openerp__.py
@@ -48,6 +48,7 @@ This module adds several restaurant features to the Point of Sale:
'static/src/xml/multiprint.xml',
'static/src/xml/splitbill.xml',
'static/src/xml/printbill.xml',
+ 'static/src/xml/floors.xml',
],
'installable': True,
'auto_install': False,
diff --git a/addons/pos_restaurant/restaurant.py b/addons/pos_restaurant/restaurant.py
index c7d02a336c1..07b61b9a2f2 100644
--- a/addons/pos_restaurant/restaurant.py
+++ b/addons/pos_restaurant/restaurant.py
@@ -28,6 +28,63 @@ from openerp.tools.translate import _
_logger = logging.getLogger(__name__)
+class restaurant_floor(osv.osv):
+ _name = 'restaurant.floor'
+ _columns = {
+ 'name': fields.char('Floor Name', size=32, required=True, help='An internal identification of the restaurant floor'),
+ 'pos_config_id': fields.many2one('pos.config','Point of Sale'),
+ 'background_image': fields.binary('Background Image', help='A background image used to display a floor layout in the point of sale interface'),
+ 'table_ids': fields.one2many('restaurant.table','floor_id','Tables', help='The list of tables in this floor'),
+ 'sequence': fields.integer('Sequence',help='Used to sort Floors'),
+ }
+
+ _defaults = {
+ 'sequence': 1,
+ }
+
+class restaurant_table(osv.osv):
+ _name = 'restaurant.table'
+ _columns = {
+ 'name': fields.char('Table Name', size=32, required=True, help='An internal identification of a table'),
+ 'floor_id': fields.many2one('restaurant.floor','Floor'),
+ 'shape': fields.selection([('square','Square'),('round','Round')],'Shape', required=True),
+ 'position_h': fields.float('Horizontal Position', help="The table's horizontal position from the left side to the table's center, in percentage of the floor's width"),
+ 'position_v': fields.float('Vertical Position', help="The table's vertical position from the top to the table's center, in percentage of the floor's height"),
+ 'width': fields.float('Width', help="The table's width in percentage of the floor's width"),
+ 'height': fields.float('Height', help="The table's height in percentage of the floor's height"),
+ 'color': fields.char('Color', size=32, help="The table's color"),
+ 'active': fields.boolean('Active',help='If false, the table is deactivated and will not be available in the point of sale'),
+ 'pos_order_ids':fields.one2many('pos.order','table_id','Pos Orders', help='The orders served at this table'),
+ }
+
+ _defaults = {
+ 'shape': 'square',
+ 'position_h': 10,
+ 'position_v': 10,
+ 'height': 50,
+ 'width': 50,
+ 'active': True,
+ }
+
+ def create_from_ui(self, cr, uid, table, context=None):
+ """ create or modify a table from the point of sale UI.
+ table contains the table's fields. If it contains an
+ id, it will modify the existing table. It then
+ returns the id of the table. """
+
+ if table.get('floor_id',False):
+ floor_id = table['floor_id'][0]
+ table['floor_id'] = floor_id
+
+ if table.get('id',False): # Modifiy existing table
+ table_id = table['id']
+ del table['id']
+ self.write(cr, uid, [table_id], table, context=context)
+ else:
+ table_id = self.create(cr, uid, table, context=context)
+
+ return table_id
+
class restaurant_printer(osv.osv):
_name = 'restaurant.printer'
@@ -46,6 +103,8 @@ class pos_config(osv.osv):
_columns = {
'iface_splitbill': fields.boolean('Bill Splitting', help='Enables Bill Splitting in the Point of Sale'),
'iface_printbill': fields.boolean('Bill Printing', help='Allows to print the Bill before payment'),
+ 'iface_orderline_notes': fields.boolean('Orderline Notes', help='Allow custom notes on Orderlines'),
+ 'floor_ids': fields.one2many('restaurant.floor','pos_config_id','Restaurant Floors', help='The restaurant floors served by this point of sale'),
'printer_ids': fields.many2many('restaurant.printer','pos_config_printer_rel', 'config_id','printer_id',string='Order Printers'),
}
_defaults = {
@@ -53,3 +112,9 @@ class pos_config(osv.osv):
'iface_printbill': False,
}
+class pos_order(osv.osv):
+ _inherit = 'pos.order'
+ _columns = {
+ 'table_id': fields.many2one('restaurant.table','Table', help='The table where this order was served'),
+ }
+
diff --git a/addons/pos_restaurant/restaurant_view.xml b/addons/pos_restaurant/restaurant_view.xml
index 84adc883b94..b676db3f8f3 100644
--- a/addons/pos_restaurant/restaurant_view.xml
+++ b/addons/pos_restaurant/restaurant_view.xml
@@ -1,6 +1,88 @@
+
+
+
+
+ Restaurant Floors
+ restaurant.floor
+
+
+
+
+
+
+ Restaurant Floors
+ restaurant.floor
+
+
+
+
+
+
+
+
+
+
+ Floor Plans
+ ir.actions.act_window
+ restaurant.floor
+ form
+ tree,form
+
+
+ Click to add a Restaurant Floor.
+
+ A restaurant floor represents the place where customers are served, this is where you can
+ define and position the tables.
+
+
+
+
+
+ Restaurant Table
+ restaurant.table
+
+
+
+
+
+
+
+
+
Order Printerrestaurant.printer
@@ -64,6 +146,8 @@
+
+
diff --git a/addons/pos_restaurant/security/ir.model.access.csv b/addons/pos_restaurant/security/ir.model.access.csv
index 31c7f956f86..57a39f69552 100644
--- a/addons/pos_restaurant/security/ir.model.access.csv
+++ b/addons/pos_restaurant/security/ir.model.access.csv
@@ -1,3 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_restaurant_printer,restaurant.printer.user,model_restaurant_printer,point_of_sale.group_pos_user,1,0,0,0
-access_restaurant_printer_manager,restaurant.printer.manager,model_restaurant_printer,point_of_sale.group_pos_manager,1,0,0,0
+access_restaurant_printer_manager,restaurant.printer.manager,model_restaurant_printer,point_of_sale.group_pos_manager,1,1,1,1
+access_restaurant_floor,restaurant.floor.user,model_restaurant_floor,point_of_sale.group_pos_user,1,0,0,0
+access_restaurant_floor_manager,restaurant.floor.manager,model_restaurant_floor,point_of_sale.group_pos_manager,1,1,1,1
+access_restaurant_table,restaurant.table.user,model_restaurant_table,point_of_sale.group_pos_user,1,0,0,0
+access_restaurant_table_manager,restaurant.table.manager,model_restaurant_table,point_of_sale.group_pos_manager,1,1,1,1
diff --git a/addons/pos_restaurant/static/lib/js/jquery.event.drag-2.2.js b/addons/pos_restaurant/static/lib/js/jquery.event.drag-2.2.js
new file mode 100644
index 00000000000..450e3044c0e
--- /dev/null
+++ b/addons/pos_restaurant/static/lib/js/jquery.event.drag-2.2.js
@@ -0,0 +1,402 @@
+/*!
+ * jquery.event.drag - v 2.2
+ * Copyright (c) 2010 Three Dub Media - http://threedubmedia.com
+ * Open Source MIT License - http://threedubmedia.com/code/license
+ */
+// Created: 2008-06-04
+// Updated: 2012-05-21
+// REQUIRES: jquery 1.7.x
+
+;(function( $ ){
+
+// add the jquery instance method
+$.fn.drag = function( str, arg, opts ){
+ // figure out the event type
+ var type = typeof str == "string" ? str : "",
+ // figure out the event handler...
+ fn = $.isFunction( str ) ? str : $.isFunction( arg ) ? arg : null;
+ // fix the event type
+ if ( type.indexOf("drag") !== 0 )
+ type = "drag"+ type;
+ // were options passed
+ opts = ( str == fn ? arg : opts ) || {};
+ // trigger or bind event handler
+ return fn ? this.bind( type, opts, fn ) : this.trigger( type );
+};
+
+// local refs (increase compression)
+var $event = $.event,
+$special = $event.special,
+// configure the drag special event
+drag = $special.drag = {
+
+ // these are the default settings
+ defaults: {
+ which: 1, // mouse button pressed to start drag sequence
+ distance: 0, // distance dragged before dragstart
+ not: ':input', // selector to suppress dragging on target elements
+ handle: null, // selector to match handle target elements
+ relative: false, // true to use "position", false to use "offset"
+ drop: true, // false to suppress drop events, true or selector to allow
+ click: false // false to suppress click events after dragend (no proxy)
+ },
+
+ // the key name for stored drag data
+ datakey: "dragdata",
+
+ // prevent bubbling for better performance
+ noBubble: true,
+
+ // count bound related events
+ add: function( obj ){
+ // read the interaction data
+ var data = $.data( this, drag.datakey ),
+ // read any passed options
+ opts = obj.data || {};
+ // count another realted event
+ data.related += 1;
+ // extend data options bound with this event
+ // don't iterate "opts" in case it is a node
+ $.each( drag.defaults, function( key, def ){
+ if ( opts[ key ] !== undefined )
+ data[ key ] = opts[ key ];
+ });
+ },
+
+ // forget unbound related events
+ remove: function(){
+ $.data( this, drag.datakey ).related -= 1;
+ },
+
+ // configure interaction, capture settings
+ setup: function(){
+ // check for related events
+ if ( $.data( this, drag.datakey ) )
+ return;
+ // initialize the drag data with copied defaults
+ var data = $.extend({ related:0 }, drag.defaults );
+ // store the interaction data
+ $.data( this, drag.datakey, data );
+ // bind the mousedown event, which starts drag interactions
+ $event.add( this, "touchstart mousedown", drag.init, data );
+ // prevent image dragging in IE...
+ if ( this.attachEvent )
+ this.attachEvent("ondragstart", drag.dontstart );
+ },
+
+ // destroy configured interaction
+ teardown: function(){
+ var data = $.data( this, drag.datakey ) || {};
+ // check for related events
+ if ( data.related )
+ return;
+ // remove the stored data
+ $.removeData( this, drag.datakey );
+ // remove the mousedown event
+ $event.remove( this, "touchstart mousedown", drag.init );
+ // enable text selection
+ drag.textselect( true );
+ // un-prevent image dragging in IE...
+ if ( this.detachEvent )
+ this.detachEvent("ondragstart", drag.dontstart );
+ },
+
+ // initialize the interaction
+ init: function( event ){
+ // sorry, only one touch at a time
+ if ( drag.touched )
+ return;
+ // the drag/drop interaction data
+ var dd = event.data, results;
+ // check the which directive
+ if ( event.which != 0 && dd.which > 0 && event.which != dd.which )
+ return;
+ // check for suppressed selector
+ if ( $( event.target ).is( dd.not ) )
+ return;
+ // check for handle selector
+ if ( dd.handle && !$( event.target ).closest( dd.handle, event.currentTarget ).length )
+ return;
+
+ drag.touched = event.type == 'touchstart' ? this : null;
+ dd.propagates = 1;
+ dd.mousedown = this;
+ dd.interactions = [ drag.interaction( this, dd ) ];
+ dd.target = event.target;
+ dd.pageX = event.pageX;
+ dd.pageY = event.pageY;
+ dd.dragging = null;
+ // handle draginit event...
+ results = drag.hijack( event, "draginit", dd );
+ // early cancel
+ if ( !dd.propagates )
+ return;
+ // flatten the result set
+ results = drag.flatten( results );
+ // insert new interaction elements
+ if ( results && results.length ){
+ dd.interactions = [];
+ $.each( results, function(){
+ dd.interactions.push( drag.interaction( this, dd ) );
+ });
+ }
+ // remember how many interactions are propagating
+ dd.propagates = dd.interactions.length;
+ // locate and init the drop targets
+ if ( dd.drop !== false && $special.drop )
+ $special.drop.handler( event, dd );
+ // disable text selection
+ drag.textselect( false );
+ // bind additional events...
+ if ( drag.touched )
+ $event.add( drag.touched, "touchmove touchend", drag.handler, dd );
+ else
+ $event.add( document, "mousemove mouseup", drag.handler, dd );
+ // helps prevent text selection or scrolling
+ if ( !drag.touched || dd.live )
+ return false;
+ },
+
+ // returns an interaction object
+ interaction: function( elem, dd ){
+ var offset = $( elem )[ dd.relative ? "position" : "offset" ]() || { top:0, left:0 };
+ return {
+ drag: elem,
+ callback: new drag.callback(),
+ droppable: [],
+ offset: offset
+ };
+ },
+
+ // handle drag-releatd DOM events
+ handler: function( event ){
+ // read the data before hijacking anything
+ var dd = event.data;
+ // handle various events
+ switch ( event.type ){
+ // mousemove, check distance, start dragging
+ case !dd.dragging && 'touchmove':
+ event.preventDefault();
+ case !dd.dragging && 'mousemove':
+ // drag tolerance, x² + y² = distance²
+ if ( Math.pow( event.pageX-dd.pageX, 2 ) + Math.pow( event.pageY-dd.pageY, 2 ) < Math.pow( dd.distance, 2 ) )
+ break; // distance tolerance not reached
+ event.target = dd.target; // force target from "mousedown" event (fix distance issue)
+ drag.hijack( event, "dragstart", dd ); // trigger "dragstart"
+ if ( dd.propagates ) // "dragstart" not rejected
+ dd.dragging = true; // activate interaction
+ // mousemove, dragging
+ case 'touchmove':
+ event.preventDefault();
+ case 'mousemove':
+ if ( dd.dragging ){
+ // trigger "drag"
+ drag.hijack( event, "drag", dd );
+ if ( dd.propagates ){
+ // manage drop events
+ if ( dd.drop !== false && $special.drop )
+ $special.drop.handler( event, dd ); // "dropstart", "dropend"
+ break; // "drag" not rejected, stop
+ }
+ event.type = "mouseup"; // helps "drop" handler behave
+ }
+ // mouseup, stop dragging
+ case 'touchend':
+ case 'mouseup':
+ default:
+ if ( drag.touched )
+ $event.remove( drag.touched, "touchmove touchend", drag.handler ); // remove touch events
+ else
+ $event.remove( document, "mousemove mouseup", drag.handler ); // remove page events
+ if ( dd.dragging ){
+ if ( dd.drop !== false && $special.drop )
+ $special.drop.handler( event, dd ); // "drop"
+ drag.hijack( event, "dragend", dd ); // trigger "dragend"
+ }
+ drag.textselect( true ); // enable text selection
+ // if suppressing click events...
+ if ( dd.click === false && dd.dragging )
+ $.data( dd.mousedown, "suppress.click", new Date().getTime() + 5 );
+ dd.dragging = drag.touched = false; // deactivate element
+ break;
+ }
+ },
+
+ // re-use event object for custom events
+ hijack: function( event, type, dd, x, elem ){
+ // not configured
+ if ( !dd )
+ return;
+ // remember the original event and type
+ var orig = { event:event.originalEvent, type:event.type },
+ // is the event drag related or drog related?
+ mode = type.indexOf("drop") ? "drag" : "drop",
+ // iteration vars
+ result, i = x || 0, ia, $elems, callback,
+ len = !isNaN( x ) ? x : dd.interactions.length;
+ // modify the event type
+ event.type = type;
+ // remove the original event
+ event.originalEvent = null;
+ // initialize the results
+ dd.results = [];
+ // handle each interacted element
+ do if ( ia = dd.interactions[ i ] ){
+ // validate the interaction
+ if ( type !== "dragend" && ia.cancelled )
+ continue;
+ // set the dragdrop properties on the event object
+ callback = drag.properties( event, dd, ia );
+ // prepare for more results
+ ia.results = [];
+ // handle each element
+ $( elem || ia[ mode ] || dd.droppable ).each(function( p, subject ){
+ // identify drag or drop targets individually
+ callback.target = subject;
+ // force propagtion of the custom event
+ event.isPropagationStopped = function(){ return false; };
+ // handle the event
+ result = subject ? $event.dispatch.call( subject, event, callback ) : null;
+ // stop the drag interaction for this element
+ if ( result === false ){
+ if ( mode == "drag" ){
+ ia.cancelled = true;
+ dd.propagates -= 1;
+ }
+ if ( type == "drop" ){
+ ia[ mode ][p] = null;
+ }
+ }
+ // assign any dropinit elements
+ else if ( type == "dropinit" )
+ ia.droppable.push( drag.element( result ) || subject );
+ // accept a returned proxy element
+ if ( type == "dragstart" )
+ ia.proxy = $( drag.element( result ) || ia.drag )[0];
+ // remember this result
+ ia.results.push( result );
+ // forget the event result, for recycling
+ delete event.result;
+ // break on cancelled handler
+ if ( type !== "dropinit" )
+ return result;
+ });
+ // flatten the results
+ dd.results[ i ] = drag.flatten( ia.results );
+ // accept a set of valid drop targets
+ if ( type == "dropinit" )
+ ia.droppable = drag.flatten( ia.droppable );
+ // locate drop targets
+ if ( type == "dragstart" && !ia.cancelled )
+ callback.update();
+ }
+ while ( ++i < len )
+ // restore the original event & type
+ event.type = orig.type;
+ event.originalEvent = orig.event;
+ // return all handler results
+ return drag.flatten( dd.results );
+ },
+
+ // extend the callback object with drag/drop properties...
+ properties: function( event, dd, ia ){
+ var obj = ia.callback;
+ // elements
+ obj.drag = ia.drag;
+ obj.proxy = ia.proxy || ia.drag;
+ // starting mouse position
+ obj.startX = dd.pageX;
+ obj.startY = dd.pageY;
+ // current distance dragged
+ obj.deltaX = event.pageX - dd.pageX;
+ obj.deltaY = event.pageY - dd.pageY;
+ // original element position
+ obj.originalX = ia.offset.left;
+ obj.originalY = ia.offset.top;
+ // adjusted element position
+ obj.offsetX = obj.originalX + obj.deltaX;
+ obj.offsetY = obj.originalY + obj.deltaY;
+ // assign the drop targets information
+ obj.drop = drag.flatten( ( ia.drop || [] ).slice() );
+ obj.available = drag.flatten( ( ia.droppable || [] ).slice() );
+ return obj;
+ },
+
+ // determine is the argument is an element or jquery instance
+ element: function( arg ){
+ if ( arg && ( arg.jquery || arg.nodeType == 1 ) )
+ return arg;
+ },
+
+ // flatten nested jquery objects and arrays into a single dimension array
+ flatten: function( arr ){
+ return $.map( arr, function( member ){
+ return member && member.jquery ? $.makeArray( member ) :
+ member && member.length ? drag.flatten( member ) : member;
+ });
+ },
+
+ // toggles text selection attributes ON (true) or OFF (false)
+ textselect: function( bool ){
+ $( document )[ bool ? "unbind" : "bind" ]("selectstart", drag.dontstart )
+ .css("MozUserSelect", bool ? "" : "none" );
+ // .attr("unselectable", bool ? "off" : "on" )
+ document.unselectable = bool ? "off" : "on";
+ },
+
+ // suppress "selectstart" and "ondragstart" events
+ dontstart: function(){
+ return false;
+ },
+
+ // a callback instance contructor
+ callback: function(){}
+
+};
+
+// callback methods
+drag.callback.prototype = {
+ update: function(){
+ if ( $special.drop && this.available.length )
+ $.each( this.available, function( i ){
+ $special.drop.locate( this, i );
+ });
+ }
+};
+
+// patch $.event.$dispatch to allow suppressing clicks
+var $dispatch = $event.dispatch;
+$event.dispatch = function( event ){
+ if ( $.data( this, "suppress."+ event.type ) - new Date().getTime() > 0 ){
+ $.removeData( this, "suppress."+ event.type );
+ return;
+ }
+ return $dispatch.apply( this, arguments );
+};
+
+// event fix hooks for touch events...
+var touchHooks =
+$event.fixHooks.touchstart =
+$event.fixHooks.touchmove =
+$event.fixHooks.touchend =
+$event.fixHooks.touchcancel = {
+ props: "clientX clientY pageX pageY screenX screenY".split( " " ),
+ filter: function( event, orig ) {
+ if ( orig ){
+ var touched = ( orig.touches && orig.touches[0] )
+ || ( orig.changedTouches && orig.changedTouches[0] )
+ || null;
+ // iOS webkit: touchstart, touchmove, touchend
+ if ( touched )
+ $.each( touchHooks.props, function( i, prop ){
+ event[ prop ] = touched[ prop ];
+ });
+ }
+ return event;
+ }
+};
+
+// share the same special event configuration with related events...
+$special.draginit = $special.dragstart = $special.dragend = drag;
+
+})( jQuery );
diff --git a/addons/pos_restaurant/static/src/css/restaurant.css b/addons/pos_restaurant/static/src/css/restaurant.css
index 0ffebfc0a46..24565e5c755 100644
--- a/addons/pos_restaurant/static/src/css/restaurant.css
+++ b/addons/pos_restaurant/static/src/css/restaurant.css
@@ -1,2 +1,243 @@
/* --- Restaurant Specific CSS --- */
+.screen .screen-content-flexbox {
+ margin: 0px auto;
+ max-width: 1024px;
+ text-align: left;
+ height: 100%;
+ overflow: hidden;
+ position: relative;
+ display: -webkit-flex;
+ -webkit-flex-flow: column nowrap;
+ flex-flow: column nowrap;
+}
+
+/* ------ FLOOR SELECTOR ------- */
+
+.floor-selector {
+ line-height: 48px;
+ font-size: 18px;
+ display: -webkit-flex;
+ display: flex;
+ text-align: center;
+ width: 100%;
+}
+.floor-selector .button {
+ cursor: pointer;
+ border-left: dashed 1px rgb(196,196,196);
+ -webkit-flex: 1;
+ flex: 1;
+}
+.floor-selector .button:first-child {
+ border-left: none;
+}
+.floor-selector .button.active {
+ background: #6EC89B;
+ color: white;
+}
+
+/* ------ FLOOR MAP ------- */
+
+.floor-map {
+ -webkit-flex: 1;
+ flex: 1;
+ position: relative;
+ width: auto;
+ height: 100%;
+ box-shadow: 0px 6px 0px -3px rgba(0,0,0,0.07) inset;
+ background: #D8D7D7;
+ background-repeat: no-repeat;
+ overflow: hidden;
+}
+
+@media screen and (min-width: 1024px) {
+ .floor-map {
+ margin: 8px;
+ border-radius: 3px;
+ }
+ .floor-selector {
+ margin: 8px;
+ margin-bottom: 0;
+ box-sizing: border-box;
+ width: auto;
+ border: none;
+ }
+ .floor-selector .button {
+ border-left: none;
+ }
+ .floor-selector .button.active{
+ border-radius: 3px;
+ }
+}
+
+.floor-map .table{
+ position: absolute;
+ text-align: center;
+ font-size: 18px;
+ color: white;
+ background: rgb(53, 211, 116);
+ border-radius: 3px;
+ cursor: pointer;
+ box-shadow: 0px 3px rgba(0,0,0,0.07);
+}
+.floor-map .table .label {
+ display: block;
+ max-height: 100%;
+ overflow: hidden;
+}
+.floor-map .table.selected {
+ outline: solid rgba(255,255,255,0.3);
+ cursor: move;
+ z-index: 50;
+}
+.floor-map .edit-button.editing {
+ position: absolute;
+ top: 0;
+ right: 0;
+ font-size: 20px;
+ margin: 4px;
+ line-height: 32px;
+ width: 32px;
+ text-align: center;
+ border-radius: 5px;
+ cursor: pointer;
+ border: solid 1px rgba(0,0,0,0.2);
+}
+.floor-map .edit-button.editing.active {
+ background: #444;
+ color: white;
+}
+.floor-map .edit-bar {
+ position: absolute;
+ top: 0;
+ right: 40px;
+ margin: 4px;
+ line-height: 32px;
+ text-align: center;
+ border-radius: 5px;
+ cursor: pointer;
+ font-size: 20px;
+ border: solid 1px rgba(0,0,0,0.2);
+ background: #F0EEEE;
+}
+.floor-map .edit-bar .edit-button {
+ position: relative;
+ width: 32px;
+ display: inline-block;
+ cursor: pointer;
+ margin-right: -4px;
+ border-right: solid 1px rgba(0,0,0,0.1);
+ transition: all 150ms linear;
+}
+.floor-map .edit-bar .edit-button.disabled {
+ opacity: 0.5;
+ border-right: solid 1px rgba(0,0,0,0.2);
+ cursor: default;
+}
+.floor-map .edit-bar .color-picker {
+ position: absolute;
+ left: -106px;
+ top: 40px;
+ width: 180px;
+ height: 180px;
+ border-radius: 3px;
+ z-index: 100;
+}
+.floor-map .edit-bar .color-picker .color {
+ display: block;
+ float: left;
+ cursor: pointer;
+ width: 60px;
+ height: 60px;
+ background-color: gray;
+}
+.floor-map .edit-bar .color-picker .color.tl { border-top-left-radius: 3px; }
+.floor-map .edit-bar .color-picker .color.tr { border-top-right-radius: 3px; }
+.floor-map .edit-bar .color-picker .color.bl { border-bottom-left-radius: 3px; }
+.floor-map .edit-bar .color-picker .color.br { border-bottom-right-radius: 3px; }
+
+.floor-map .edit-bar .close-picker {
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ margin-left: -16px;
+ margin-bottom: -16px;
+ width: 32px;
+ height: 32px;
+ line-height: 32px;
+ text-align: center;
+ font-size: 20px;
+ border-radius: 16px;
+ background: black;
+ color: white;
+ cursor: pointer;
+}
+
+.floor-map .edit-bar .edit-button:last-child {
+ margin-right: 0;
+ border-right: none;
+}
+
+.floor-map .table.selected .table-handle {
+ position: absolute;
+ width: 16px;
+ height: 16px;
+ left: 50%;
+ top: 50%;
+ border-radius: 8px;
+ margin-left: -8px;
+ margin-top: -8px;
+ background: white;
+ box-shadow: 0px 2px 3px rgba(0,0,0,0.2);
+ cursor: grab;
+ transition: all 150ms linear;
+}
+.floor-map .table.selected .table-handle:hover {
+ width: 20px;
+ height: 20px;
+ border-radius: 10px;
+ margin-left: -10px;
+ margin-top: -10px;
+}
+.floor-map .table .table-handle.top { top: 0; }
+.floor-map .table .table-handle.bottom { top: 100%; }
+.floor-map .table .table-handle.left { left: 0; }
+.floor-map .table .table-handle.right { left: 100%; }
+
+.floor-map .table .order-count {
+ position: absolute;
+ top: 0;
+ left: 50%;
+ background: black;
+ width: 20px;
+ margin-top: -10px;
+ margin-left: -10px;
+ height: 20px;
+ line-height: 20px;
+ border-radius: 10px;
+ font-size: 16px;
+}
+.floor-map .table .order-count.notify-printing {
+ background: red;
+}
+
+
+/* ------ FLOOR BUTTON IN THE ORDER SELECTOR ------- */
+
+.pos .order-button.floor-button {
+ background: #6EC89B;
+ font-weight: bold;
+ font-size: 16px;
+ min-width: 128px;
+ padding-left: 16px;
+ padding-right: 16px;
+}
+.pos .order-button.floor-button .table-name {
+ font-weight: normal;
+}
+.pos .order-button.floor-button .fa{
+ font-size: 24px;
+}
+
+
+
diff --git a/addons/pos_restaurant/static/src/js/floors.js b/addons/pos_restaurant/static/src/js/floors.js
new file mode 100644
index 00000000000..686593eb887
--- /dev/null
+++ b/addons/pos_restaurant/static/src/js/floors.js
@@ -0,0 +1,759 @@
+function openerp_restaurant_floors(instance,module){
+ var QWeb = instance.web.qweb;
+ var _t = instance.web._t;
+
+ // At POS Startup, load the floors, and add them to the pos model
+ module.PosModel.prototype.models.push({
+ model: 'restaurant.floor',
+ fields: ['name','background_image','table_ids','sequence'],
+ domain: function(self){ return [['pos_config_id','=',self.config.id]] },
+ loaded: function(self,floors){
+ self.floors = floors;
+ self.floors_by_id = {};
+ for (var i = 0; i < floors.length; i++) {
+ floors[i].tables = [];
+ self.floors_by_id[floors[i].id] = floors[i];
+ }
+
+ // Make sure they display in the correct order
+ self.floors = self.floors.sort(function(a,b){ return a.sequence - b.sequence; });
+
+ // Ignore floorplan features if no floor specified.
+ self.config.iface_floorplan = !!self.floors.length;
+ },
+ });
+
+ // At POS Startup, after the floors are loaded, load the tables, and associate
+ // them with their floor.
+ module.PosModel.prototype.models.push({
+ model: 'restaurant.table',
+ fields: ['name','width','height','position_h','position_v','shape','floor_id','color'],
+ loaded: function(self,tables){
+ self.tables_by_id = {};
+ for (var i = 0; i < tables.length; i++) {
+ self.tables_by_id[tables[i].id] = tables[i];
+ var floor = self.floors_by_id[tables[i].floor_id[0]];
+ if (floor) {
+ floor.tables.push(tables[i]);
+ tables[i].floor = floor;
+ }
+ }
+ },
+ });
+
+ // The Table GUI element, should always be a child of the FloorScreenWidget
+ module.TableWidget = module.PosBaseWidget.extend({
+ template: 'TableWidget',
+ init: function(parent, options){
+ this._super(parent, options)
+ this.table = options.table;
+ this.selected = false;
+ this.moved = false;
+ this.dragpos = {x:0, y:0};
+ this.handle_dragging = false;
+ this.handle = null;
+ },
+ // computes the absolute position of a DOM mouse event, used
+ // when resizing tables
+ event_position: function(event){
+ if(event.touches && event.touches[0]){
+ return {x: event.touches[0].screenX, y: event.touches[0].screenY};
+ }else{
+ return {x: event.screenX, y: event.screenY};
+ }
+ },
+ // when a table is clicked, go to the table's orders
+ // but if we're editing, we select/deselect it.
+ click_handler: function(){
+ var self = this;
+ var floorplan = this.getParent();
+ if (floorplan.editing) {
+ setTimeout(function(){ // in a setTimeout to debounce with drag&drop start
+ if (!self.dragging) {
+ if (self.moved) {
+ self.moved = false;
+ } else if (!self.selected) {
+ self.getParent().select_table(self);
+ } else {
+ self.getParent().deselect_tables();
+ }
+ }
+ },50);
+ } else {
+ floorplan.pos.set_table(this.table);
+ }
+ },
+ // drag and drop for moving the table, at drag start
+ dragstart_handler: function(event,$el,drag){
+ if (this.selected && !this.handle_dragging) {
+ this.dragging = true;
+ this.dragpos = { x: drag.offsetX, y: drag.offsetY };
+ }
+ },
+ // drag and drop for moving the table, at drag end
+ dragend_handler: function(event,$el){
+ this.dragging = false;
+ },
+ // drag and drop for moving the table, at every drop movement.
+ dragmove_handler: function(event,$el,drag){
+ if (this.dragging) {
+ var dx = drag.offsetX - this.dragpos.x;
+ var dy = drag.offsetY - this.dragpos.y;
+
+ this.dragpos = { x: drag.offsetX, y: drag.offsetY };
+ this.moved = true;
+
+ this.table.position_v += dy;
+ this.table.position_h += dx;
+
+ $el.css(this.table_style());
+ }
+ },
+ // drag and dropping the resizing handles
+ handle_dragstart_handler: function(event, $el, drag) {
+ if (this.selected && !this.dragging) {
+ this.handle_dragging = true;
+ this.handle_dragpos = this.event_position(event);
+ this.handle = drag.target;
+ }
+ },
+ handle_dragend_handler: function(event, $el, drag) {
+ this.handle_dragging = false;
+ },
+ handle_dragmove_handler: function(event, $el, drag) {
+ if (this.handle_dragging) {
+ var pos = this.event_position(event);
+ var dx = pos.x - this.handle_dragpos.x;
+ var dy = pos.y - this.handle_dragpos.y;
+
+ this.handle_dragpos = pos;
+ this.moved = true;
+
+ var cl = this.handle.classList;
+
+ var MIN_SIZE = 40; // smaller than this, and it becomes impossible to edit.
+
+ var tw = Math.max(MIN_SIZE, this.table.width);
+ var th = Math.max(MIN_SIZE, this.table.height);
+ var tx = this.table.position_h;
+ var ty = this.table.position_v;
+
+ if (cl.contains('left') && tw - dx >= MIN_SIZE) {
+ tw -= dx;
+ tx += dx;
+ } else if (cl.contains('right') && tw + dx >= MIN_SIZE) {
+ tw += dx;
+ }
+
+ if (cl.contains('top') && th - dy >= MIN_SIZE) {
+ th -= dy;
+ ty += dy;
+ } else if (cl.contains('bottom') && th + dy >= MIN_SIZE) {
+ th += dy;
+ }
+
+ this.table.width = tw;
+ this.table.height = th;
+ this.table.position_h = tx;
+ this.table.position_v = ty;
+
+ this.$el.css(this.table_style());
+ }
+ },
+ set_table_color: function(color){
+ this.table.color = color;
+ this.renderElement();
+ },
+ set_table_name: function(name){
+ if (name) {
+ this.table.name = name;
+ this.renderElement();
+ }
+ },
+ // The table's positioning is handled via css absolute positioning,
+ // which is handled here.
+ table_style: function(){
+ var table = this.table;
+ function unit(val){ return '' + val + 'px'; }
+ var style = {
+ 'width': unit(table.width),
+ 'height': unit(table.height),
+ 'line-height': unit(table.height),
+ 'margin-left': unit(-table.width/2),
+ 'margin-top': unit(-table.height/2),
+ 'top': unit(table.position_v + table.height/2),
+ 'left': unit(table.position_h + table.width/2),
+ 'border-radius': table.shape === 'round' ?
+ unit(Math.max(table.width,table.height)/2) : '3px',
+ };
+ if (table.color) {
+ style['background-color'] = table.color;
+ }
+ if (table.height >= 150 && table.width >= 150) {
+ style['font-size'] = '32px';
+ }
+
+ return style;
+ },
+ // convert the style dictionary to a ; separated string for inclusion in templates
+ table_style_str: function(){
+ var style = this.table_style();
+ var str = "";
+ for (s in style) {
+ str += s + ":" + style[s] + "; ";
+ }
+ return str;
+ },
+ // select the table (should be called via the floorplan)
+ select: function() {
+ this.selected = true;
+ this.renderElement();
+ },
+ // deselect the table (should be called via the floorplan)
+ deselect: function() {
+ this.selected = false;
+ this.renderElement();
+ this.save_changes();
+ },
+ // sends the table's modification to the server
+ save_changes: function(){
+ var self = this;
+ var model = new instance.web.Model('restaurant.table');
+ var fields = _.find(this.pos.models,function(model){ return model.model === 'restaurant.table'; }).fields;
+
+ // we need a serializable copy of the table, containing only the fields defined on the server
+ var serializable_table = {};
+ for (var i = 0; i < fields.length; i++) {
+ if (typeof this.table[fields[i]] !== 'undefined') {
+ serializable_table[fields[i]] = this.table[fields[i]];
+ }
+ }
+ // and the id ...
+ serializable_table.id = this.table.id
+
+ model.call('create_from_ui',[serializable_table]).then(function(table_id){
+ model.query(fields).filter([['id','=',table_id]]).first().then(function(table){
+ for (field in table) {
+ self.table[field] = table[field];
+ }
+ self.renderElement();
+ });
+ });
+ },
+ // destroy the table. We do not really destroy it, we set it
+ // to inactive so that it doesn't show up anymore, but it still
+ // available on the database for the orders that depend on it.
+ trash: function(){
+ var self = this;
+ var model = new instance.web.Model('restaurant.table');
+ return model.call('create_from_ui',[{'active':false,'id':this.table.id}]).then(function(table_id){
+ // Removing all references from the table and the table_widget in in the UI ...
+ for (var i = 0; i < self.pos.floors.length; i++) {
+ var floor = self.pos.floors[i];
+ for (var j = 0; j < floor.tables.length; j++) {
+ if (floor.tables[j].id === table_id) {
+ floor.tables.splice(j,1);
+ break;
+ }
+ }
+ }
+ var floorplan = self.getParent();
+ for (var i = 0; i < floorplan.table_widgets.length; i++) {
+ if (floorplan.table_widgets[i] === self) {
+ floorplan.table_widgets.splice(i,1);
+ }
+ }
+ if (floorplan.selected_table === self) {
+ floorplan.selected_table = null;
+ }
+ floorplan.update_toolbar();
+ self.destroy();
+ });
+ },
+ get_notifications: function(){ //FIXME : Make this faster
+ var orders = this.pos.get_table_orders(this.table);
+ var notifications = {};
+ for (var i = 0; i < orders.length; i++) {
+ if (orders[i].hasChangesToPrint()) {
+ notifications['printing'] = true;
+ break;
+ }
+ }
+ return notifications
+ },
+ renderElement: function(){
+ var self = this;
+ this.order_count = this.pos.get_table_orders(this.table).length;
+ this.notifications = this.get_notifications();
+ this._super();
+
+ this.$el.on('mouseup', function(event){ self.click_handler(event,$(this)); });
+ this.$el.on('touchend', function(event){ self.click_handler(event,$(this)); });
+ this.$el.on('touchcancel', function(event){ self.click_handler(event,$(this)); });
+ this.$el.on('dragstart', function(event,drag){ self.dragstart_handler(event,$(this),drag); });
+ this.$el.on('drag', function(event,drag){ self.dragmove_handler(event,$(this),drag); });
+ this.$el.on('dragend', function(event,drag){ self.dragend_handler(event,$(this),drag); });
+
+ var handles = this.$el.find('.table-handle');
+ handles.on('dragstart', function(event,drag){ self.handle_dragstart_handler(event,$(this),drag); });
+ handles.on('drag', function(event,drag){ self.handle_dragmove_handler(event,$(this),drag); });
+ handles.on('dragend', function(event,drag){ self.handle_dragend_handler(event,$(this),drag); });
+ },
+ });
+
+ // The screen that allows you to select the floor, see and select the table,
+ // as well as edit them.
+ module.FloorScreenWidget = module.ScreenWidget.extend({
+ template: 'FloorScreenWidget',
+ show_leftpane: false,
+
+ // Ignore products, discounts, and client barcodes
+ barcode_product_action: function(code){},
+ barcode_discount_action: function(code){},
+ barcode_client_action: function(code){},
+
+ init: function(parent, options) {
+ this._super(parent, options);
+ this.floor = this.pos.floors[0];
+ this.table_widgets = [];
+ this.selected_table = null;
+ this.editing = false;
+ },
+ hide: function(){
+ this._super();
+ if (this.editing) {
+ this.toggle_editing();
+ }
+ this.pos_widget.order_selector.show();
+ },
+ show: function(){
+ this._super();
+ this.pos_widget.order_selector.hide();
+ for (var i = 0; i < this.table_widgets.length; i++) {
+ this.table_widgets[i].renderElement();
+ }
+ },
+ click_floor_button: function(event,$el){
+ var floor = this.pos.floors_by_id[$el.data('id')];
+ if (floor !== this.floor) {
+ if (this.editing) {
+ this.toggle_editing();
+ }
+ this.floor = floor;
+ this.selected_table = null;
+ this.renderElement();
+ }
+ },
+ background_image_url: function(floor) {
+ return '/web/binary/image?model=restaurant.floor&id='+floor.id+'&field=background_image';
+ },
+ deselect_tables: function(){
+ for (var i = 0; i < this.table_widgets.length; i++) {
+ var table = this.table_widgets[i];
+ if (table.selected) {
+ table.deselect();
+ }
+ }
+ this.selected_table = null;
+ this.update_toolbar();
+ },
+ select_table: function(table_widget){
+ if (!table_widget.selected) {
+ this.deselect_tables();
+ table_widget.select();
+ this.selected_table = table_widget;
+ this.update_toolbar();
+ }
+ },
+ tool_shape_action: function(){
+ if (this.selected_table) {
+ var table = this.selected_table.table;
+ if (table.shape === 'square') {
+ table.shape = 'round';
+ } else {
+ table.shape = 'square';
+ }
+ this.selected_table.renderElement();
+ this.update_toolbar();
+ }
+ },
+ tool_colorpicker_open: function(){
+ if (this.selected_table) {
+ this.$('.color-picker').removeClass('oe_hidden');
+ }
+ },
+ tool_colorpicker_pick: function(event,$el){
+ if (this.selected_table) {
+ this.selected_table.set_table_color($el[0].style['background-color']);
+ }
+ },
+ tool_colorpicker_close: function(){
+ this.$('.color-picker').addClass('oe_hidden');
+ },
+ tool_rename_table: function(){
+ var self = this;
+ if (this.selected_table) {
+ this.pos_widget.screen_selector.show_popup('textinput',{
+ 'message':_t('Table Name ?'),
+ 'value': this.selected_table.table.name,
+ 'confirm': function(value) {
+ self.selected_table.set_table_name(value);
+ },
+ });
+ }
+ },
+ tool_duplicate_table: function(){
+ if (this.selected_table) {
+ var tw = this.create_table(this.selected_table.table);
+ tw.table.position_h += 10;
+ tw.table.position_v += 10;
+ tw.save_changes();
+ this.select_table(tw);
+ }
+ },
+ tool_new_table: function(){
+ var tw = this.create_table({
+ 'position_v': 50,
+ 'position_h': 50,
+ 'width': 50,
+ 'height': 50,
+ 'name': 'T1',
+ 'shape': 'square',
+ });
+ this.select_table(tw);
+ },
+ create_table: function(params) {
+ var table = {};
+ for (var p in params) {
+ table[p] = params[p];
+ }
+
+ delete table['id'];
+ table.floor_id = [this.floor.id,''];
+ table.floor = this.floor;
+
+ this.floor.tables.push(table);
+ var tw = new module.TableWidget(this,{table: table});
+ tw.appendTo('.floor-map');
+ this.table_widgets.push(tw);
+ return tw;
+ },
+ tool_trash_table: function(){
+ var self = this;
+ if (this.selected_table) {
+ this.pos_widget.screen_selector.show_popup('confirm',{
+ 'message':_t('Are you sure ?'),
+ 'comment':_t('Removing a table cannot be undone'),
+ 'confirm': function(){
+ self.selected_table.trash();
+ },
+ });
+ }
+ },
+ toggle_editing: function(){
+ this.editing = !this.editing;
+ this.update_toolbar();
+
+ if (!this.editing) {
+ this.deselect_tables();
+ }
+ },
+ update_toolbar: function(){
+
+ if (this.editing) {
+ this.$('.edit-bar').removeClass('oe_hidden');
+ this.$('.edit-button.editing').addClass('active');
+ } else {
+ this.$('.edit-bar').addClass('oe_hidden');
+ this.$('.edit-button.editing').removeClass('active');
+ }
+
+ if (this.selected_table) {
+ this.$('.needs-selection').removeClass('disabled');
+ var table = this.selected_table.table;
+ if (table.shape === 'square') {
+ this.$('.button-option.square').addClass('oe_hidden');
+ this.$('.button-option.round').removeClass('oe_hidden');
+ } else {
+ this.$('.button-option.square').removeClass('oe_hidden');
+ this.$('.button-option.round').addClass('oe_hidden');
+ }
+ } else {
+ this.$('.needs-selection').addClass('disabled');
+ }
+ this.tool_colorpicker_close();
+ },
+ renderElement: function(){
+ var self = this;
+
+ // cleanup table widgets from previous renders
+ for (var i = 0; i < this.table_widgets.length; i++) {
+ this.table_widgets[i].destroy();
+ }
+
+ this.table_widgets = [];
+
+ this._super();
+
+ for (var i = 0; i < this.floor.tables.length; i++) {
+ var tw = new module.TableWidget(this,{
+ table: this.floor.tables[i],
+ });
+ tw.appendTo(this.$('.floor-map'));
+ this.table_widgets.push(tw);
+ }
+
+ this.$('.floor-selector .button').click(function(event){
+ self.click_floor_button(event,$(this));
+ });
+
+ this.$('.edit-button.shape').click(function(event){
+ self.tool_shape_action();
+ });
+
+ this.$('.edit-button.color').click(function(event){
+ self.tool_colorpicker_open();
+ });
+
+ this.$('.edit-button.dup-table').click(function(event){
+ self.tool_duplicate_table();
+ });
+
+ this.$('.edit-button.new-table').click(function(event){
+ self.tool_new_table();
+ });
+
+ this.$('.edit-button.rename').click(function(event){
+ self.tool_rename_table();
+ });
+
+ this.$('.edit-button.trash').click(function(event){
+ self.tool_trash_table();
+ });
+
+ this.$('.color-picker .close-picker').click(function(event){
+ self.tool_colorpicker_close();
+ event.stopPropagation();
+ });
+
+ this.$('.color-picker .color').click(function(event){
+ self.tool_colorpicker_pick(event,$(this));
+ self.tool_colorpicker_close();
+ event.stopPropagation();
+ });
+
+ this.$('.edit-button.editing').click(function(){
+ self.toggle_editing();
+ });
+
+ this.$('.floor-map').click(function(event){
+ if (event.target === self.$('.floor-map')[0]) {
+ self.deselect_tables();
+ }
+ });
+
+ this.$('.color-picker .close-picker').click(function(event){
+ self.tool_colorpicker_close();
+ event.stopPropagation();
+ });
+
+ this.update_toolbar();
+
+ },
+ });
+
+ // Add the FloorScreen to the GUI, and set it as the default screen
+ module.PosWidget.include({
+ build_widgets: function(){
+ var self = this;
+ this._super();
+ if (this.pos.config.iface_floorplan) {
+ this.floors_screen = new module.FloorScreenWidget(this,{});
+ this.floors_screen.appendTo(this.$('.screens'));
+ this.screen_selector.add_screen('floors',this.floors_screen);
+ this.screen_selector.change_default_screen('floors');
+ }
+ },
+ });
+
+ // when the floor plan is activated, we need to go back to the floor plan
+ // when an order is completed. Usually on order completion, a new order is
+ // set as the current one. Now, we set the new order to null.
+ // load_saved_screen() is called whenever the current order is changed, and
+ // will detect this, and set the current screen to the default_screen,
+ // which is the floor plan.
+ module.ScreenSelector.include({
+ load_saved_screen: function(){
+ if (this.pos.config.iface_floorplan) {
+ if (!this.pos.get_order()) {
+ this.set_current_screen(this.default_screen,null,'refresh');
+ } else {
+ this._super({default_screen:'products'});
+ }
+ } else {
+ this._super.apply(this,arguments);
+ }
+ },
+ });
+
+ // New orders are now associated with the current table, if any.
+ var _super_order = module.Order.prototype;
+ module.Order = module.Order.extend({
+ initialize: function(attr) {
+ _super_order.initialize.apply(this,arguments);
+ if (!this.table) {
+ this.table = this.pos.table;
+ }
+ this.save_to_db();
+ },
+ export_as_JSON: function() {
+ var json = _super_order.export_as_JSON.apply(this,arguments);
+ json.table = this.table ? this.table.name : undefined;
+ json.table_id = this.table ? this.table.id : false;
+ json.floor = this.table ? this.table.floor.name : false;
+ json.floor_id = this.table ? this.table.floor.id : false;
+ return json;
+ },
+ init_from_JSON: function(json) {
+ _super_order.init_from_JSON.apply(this,arguments);
+ this.table = this.pos.tables_by_id[json.table_id];
+ this.floor = this.table ? this.pos.floors_by_id[json.floor_id] : undefined;
+ },
+ export_for_printing: function() {
+ var json = _super_order.export_for_printing.apply(this,arguments);
+ json.table = this.table ? this.table.name : undefined;
+ json.floor = this.table ? this.table.floor.name : undefined;
+ return json;
+ },
+ });
+
+ // We need to modify the OrderSelector to hide itself when we're on
+ // the floor plan
+ module.OrderSelectorWidget.include({
+ floor_button_click_handler: function(){
+ this.pos.set_table(null);
+ },
+ hide: function(){
+ this.$el.addClass('oe_invisible');
+ },
+ show: function(){
+ this.$el.removeClass('oe_invisible');
+ },
+ renderElement: function(){
+ var self = this;
+ this._super();
+ if (this.pos.config.iface_floorplan) {
+ if (this.pos.get_order()) {
+ if (this.pos.table && this.pos.table.floor) {
+ this.$('.orders').prepend(QWeb.render('BackToFloorButton',{table: this.pos.table, floor:this.pos.table.floor}));
+ this.$('.floor-button').click(function(){
+ self.floor_button_click_handler();
+ });
+ }
+ this.$el.removeClass('oe_invisible');
+ } else {
+ this.$el.addClass('oe_invisible');
+ }
+ }
+ },
+ });
+
+ // We need to change the way the regular UI sees the orders, it
+ // needs to only see the orders associated with the current table,
+ // and when an order is validated, it needs to go back to the floor map.
+ //
+ // And when we change the table, we must create an order for that table
+ // if there is none.
+ var _super_posmodel = module.PosModel.prototype;
+ module.PosModel = module.PosModel.extend({
+ initialize: function(session, attributes) {
+ this.table = null;
+ return _super_posmodel.initialize.call(this,session,attributes);
+ },
+
+ // changes the current table.
+ set_table: function(table) {
+ if (!table) { // no table ? go back to the floor plan, see ScreenSelector
+ this.set_order(null);
+ } else { // table ? load the associated orders ...
+ this.table = table;
+ var orders = this.get_order_list();
+ if (orders.length) {
+ this.set_order(orders[0]); // and go to the first one ...
+ } else {
+ this.add_new_order(); // or create a new order with the current table
+ }
+ }
+ },
+
+ // if we have tables, we do not load a default order, as the default order will be
+ // set when the user selects a table.
+ set_start_order: function() {
+ if (!this.config.iface_floorplan) {
+ _super_posmodel.set_start_order.apply(this,arguments);
+ }
+ },
+
+ // we need to prevent the creation of orders when there is no
+ // table selected.
+ add_new_order: function() {
+ if (this.config.iface_floorplan) {
+ if (this.table) {
+ _super_posmodel.add_new_order.call(this);
+ } else {
+ console.warn("WARNING: orders cannot be created when there is no active table in restaurant mode");
+ }
+ } else {
+ _super_posmodel.add_new_order.apply(this,arguments);
+ }
+ },
+
+
+ // get the list of unpaid orders (associated to the current table)
+ get_order_list: function() {
+ var orders = _super_posmodel.get_order_list.call(this);
+ if (!this.config.iface_floorplan) {
+ return orders;
+ } else if (!this.table) {
+ return [];
+ } else {
+ var t_orders = [];
+ for (var i = 0; i < orders.length; i++) {
+ if ( orders[i].table === this.table) {
+ t_orders.push(orders[i]);
+ }
+ }
+ return t_orders;
+ }
+ },
+
+ // get the list of orders associated to a table. FIXME: should be O(1)
+ get_table_orders: function(table) {
+ var orders = _super_posmodel.get_order_list.call(this);
+ var t_orders = [];
+ for (var i = 0; i < orders.length; i++) {
+ if (orders[i].table === table) {
+ t_orders.push(orders[i]);
+ }
+ }
+ return t_orders;
+ },
+
+ // When we validate an order we go back to the floor plan.
+ // When we cancel an order and there is multiple orders
+ // on the table, stay on the table.
+ on_removed_order: function(removed_order,index,reason){
+ if (this.config.iface_floorplan) {
+ var order_list = this.get_order_list();
+ if( (reason === 'abandon' || removed_order.temporary) && order_list.length > 0){
+ this.set_order(order_list[index] || order_list[order_list.length -1]);
+ }else{
+ // back to the floor plan
+ this.set_table(null);
+ }
+ } else {
+ _super_posmodel.on_removed_order.apply(this,arguments);
+ }
+ },
+ });
+}
diff --git a/addons/pos_restaurant/static/src/js/main.js b/addons/pos_restaurant/static/src/js/main.js
index 606eef836a8..eaafccdf114 100644
--- a/addons/pos_restaurant/static/src/js/main.js
+++ b/addons/pos_restaurant/static/src/js/main.js
@@ -2,10 +2,11 @@ openerp.pos_restaurant = function(instance){
var module = instance.point_of_sale;
- openerp_restaurant_multiprint(instance,module);
-
openerp_restaurant_splitbill(instance,module);
openerp_restaurant_printbill(instance,module);
+ openerp_restaurant_floors(instance,module);
+
+ openerp_restaurant_multiprint(instance,module);
};
diff --git a/addons/pos_restaurant/static/src/js/multiprint.js b/addons/pos_restaurant/static/src/js/multiprint.js
index 81b0e34053a..c4343cbf2d6 100644
--- a/addons/pos_restaurant/static/src/js/multiprint.js
+++ b/addons/pos_restaurant/static/src/js/multiprint.js
@@ -54,26 +54,43 @@ function openerp_restaurant_multiprint(instance,module){
},
});
+ module.Orderline = module.Orderline.extend({
+ get_line_diff_hash: function(){
+ if (this.get_note()) {
+ return this.get_product().id + '|' + this.get_note();
+ } else {
+ return '' + this.get_product().id;
+ }
+ },
+ });
+
+ var _super_order = module.Order.prototype;
module.Order = module.Order.extend({
lineResume: function(){
var resume = {};
- this.get('orderLines').each(function(item){
- var line = item.export_as_JSON();
- if( typeof resume[line.product_id] === 'undefined'){
- resume[line.product_id] = line.qty;
- }else{
- resume[line.product_id] += line.qty;
+
+ this.orderlines.each(function(line){
+ var line_hash = line.get_line_diff_hash();
+ var qty = Number(line.get_quantity());
+ var note = line.get_note();
+ var product_id = line.get_product().id;
+
+ if (typeof resume[line_hash] === 'undefined') {
+ resume[line_hash] = { qty: qty, note: note, product_id: product_id };
+ } else {
+ resume[line_hash].qty += qty;
}
});
return resume;
},
saveChanges: function(){
- this.old_resume = this.lineResume();
+ this.saved_resume = this.build_line_resume();
+ this.trigger('change',this);
},
computeChanges: function(categories){
- var current = this.lineResume();
- var old = this.old_resume || {};
- var json = this.export_as_JSON();
+ var current_res = this.build_line_resume();
+ var old_res = this.saved_resume || {};
+ var json = this.export_as_JSON();
var add = [];
var rem = [];
@@ -145,11 +162,22 @@ function openerp_restaurant_multiprint(instance,module){
rem = _rem;
}
+ var d = new Date();
+ var hours = '' + d.getHours();
+ hours = hours.length < 2 ? ('0' + hours) : hours;
+ var minutes = '' + d.getMinutes();
+ minutes = minutes.length < 2 ? ('0' + minutes) : minutes;
+
return {
'new': add,
'cancelled': rem,
- 'table': json.table || 'unknown table',
+ 'table': json.table || false,
+ 'floor': json.floor || false,
'name': json.name || 'unknown order',
+ 'time': {
+ 'hours': hours,
+ 'minutes': minutes,
+ },
};
},
@@ -173,6 +201,15 @@ function openerp_restaurant_multiprint(instance,module){
}
return false;
},
+ export_as_JSON: function(){
+ var json = _super_order.export_as_JSON.apply(this,arguments);
+ json.multiprint_resume = this.saved_resume;
+ return json;
+ },
+ init_from_JSON: function(json){
+ _super_order.init_from_JSON.apply(this,arguments);
+ this.saved_resume = json.multiprint_resume;
+ },
});
module.PosWidget.include({
diff --git a/addons/pos_restaurant/static/src/js/notes.js b/addons/pos_restaurant/static/src/js/notes.js
new file mode 100644
index 00000000000..1e4c9489f22
--- /dev/null
+++ b/addons/pos_restaurant/static/src/js/notes.js
@@ -0,0 +1,71 @@
+function openerp_restaurant_notes(instance,module){
+ "use strict";
+
+ var QWeb = instance.web.qweb;
+ var _t = instance.web._t;
+
+ var _super_orderline = module.Orderline.prototype;
+
+ module.Orderline = module.Orderline.extend({
+ initialize: function(attr, options) {
+ _super_orderline.initialize.call(this,attr,options);
+ this.note = this.note || "";
+ },
+ set_note: function(note){
+ this.note = note;
+ this.trigger('change',this);
+ },
+ get_note: function(note){
+ return this.note;
+ },
+ can_be_merged_with: function(orderline) {
+ if (orderline.get_note() !== this.get_note()) {
+ return false;
+ } else {
+ return _super_orderline.can_be_merged_with.call(this,orderline);
+ }
+ },
+ clone: function(){
+ var orderline = _super_orderline.clone.call(this);
+ orderline.note = this.note;
+ return orderline;
+ },
+ export_as_JSON: function(){
+ var json = _super_orderline.export_as_JSON.call(this);
+ json.note = this.note;
+ return json;
+ },
+ init_from_JSON: function(json){
+ _super_orderline.init_from_JSON.apply(this,arguments);
+ this.note = json.note;
+ },
+ });
+
+ module.PosWidget.include({
+ orderline_note_click: function(){
+ var self = this;
+ var line = this.pos.get_order().get_selected_orderline();
+
+ if (line) {
+ this.screen_selector.show_popup('textarea',{
+ message: _t('Orderline Note'),
+ value: line.get_note(),
+ confirm: function(note) {
+ line.set_note(note);
+ },
+ });
+ }
+ },
+ build_widgets: function(){
+ var self = this;
+ this._super();
+
+ if (this.pos.config.iface_orderline_notes) {
+ var button = $(QWeb.render('OrderlineNoteButton'));
+ button.click(function(){ self.orderline_note_click(); });
+ button.appendTo(this.$('.control-buttons'));
+ this.$('.control-buttons').removeClass('oe_hidden');
+ }
+ },
+ });
+}
diff --git a/addons/pos_restaurant/static/src/js/printbill.js b/addons/pos_restaurant/static/src/js/printbill.js
index 89bf9db7bc7..c8bfc8ec93e 100644
--- a/addons/pos_restaurant/static/src/js/printbill.js
+++ b/addons/pos_restaurant/static/src/js/printbill.js
@@ -12,10 +12,11 @@ function openerp_restaurant_printbill(instance,module){
printbill.click(function(){
var order = self.pos.get('selectedOrder');
- if(order.get('orderLines').models.length > 0){
+ if(order.get_orderlines().length > 0){
var receipt = order.export_for_printing();
+ receipt.bill = true;
self.pos.proxy.print_receipt(QWeb.render('BillReceipt',{
- receipt: receipt, widget: self,
+ receipt: receipt, widget: self, pos: self.pos, order: order,
}));
}
});
diff --git a/addons/pos_restaurant/static/src/js/splitbill.js b/addons/pos_restaurant/static/src/js/splitbill.js
index 81591ccc589..f58f77eedc8 100644
--- a/addons/pos_restaurant/static/src/js/splitbill.js
+++ b/addons/pos_restaurant/static/src/js/splitbill.js
@@ -11,11 +11,11 @@ function openerp_restaurant_splitbill(instance, module){
renderElement: function(){
var self = this;
this._super();
- var order = this.pos.get('selectedOrder');
+ var order = this.pos.get_order();
if(!order){
return;
}
- var orderlines = order.get('orderLines').models;
+ var orderlines = order.get_orderlines();
for(var i = 0; i < orderlines.length; i++){
var line = orderlines[i];
linewidget = $(QWeb.render('SplitOrderline',{
@@ -35,7 +35,7 @@ function openerp_restaurant_splitbill(instance, module){
lineselect: function($el,order,neworder,splitlines,line_id){
var split = splitlines[line_id] || {'quantity': 0, line: null};
- var line = order.getOrderline(line_id);
+ var line = order.get_orderline(line_id);
if( !line.get_unit().groupable ){
if( split.quantity !== line.get_quantity()){
@@ -57,11 +57,11 @@ function openerp_restaurant_splitbill(instance, module){
if( split.quantity ){
if ( !split.line ){
split.line = line.clone();
- neworder.addOrderline(split.line);
+ neworder.add_orderline(split.line);
}
split.line.set_quantity(split.quantity);
}else if( split.line ) {
- neworder.removeOrderline(split.line);
+ neworder.remove_orderline(split.line);
split.line = null;
}
@@ -73,11 +73,11 @@ function openerp_restaurant_splitbill(instance, module){
quantity: split.quantity,
id: line_id,
})));
- this.$('.order-info .subtotal').text(this.format_currency(neworder.getSubtotal()));
+ this.$('.order-info .subtotal').text(this.format_currency(neworder.get_subtotal()));
},
- pay: function($el,order,neworder,splitlines,cashregister_id){
- var orderlines = order.get('orderLines').models;
+ pay: function(order,neworder,splitlines){
+ var orderlines = order.get_orderlines();
var empty = true;
var full = true;
@@ -100,27 +100,19 @@ function openerp_restaurant_splitbill(instance, module){
return;
}
- for(var i = 0; i < this.pos.cashregisters.length; i++){
- if(this.pos.cashregisters[i].id === cashregister_id){
- var cashregister = this.pos.cashregisters[i];
- break;
- }
- }
if(full){
- order.addPaymentline(cashregister);
this.pos_widget.screen_selector.set_current_screen('payment');
}else{
for(var id in splitlines){
var split = splitlines[id];
- var line = order.getOrderline(parseInt(id));
+ var line = order.get_orderline(parseInt(id));
line.set_quantity(line.get_quantity() - split.quantity);
if(Math.abs(line.get_quantity()) < 0.00001){
- order.removeOrderline(line);
+ order.remove_orderline(line);
}
delete splitlines[id];
}
- neworder.addPaymentline(cashregister);
neworder.set_screen_data('screen','payment');
// for the kitchen printer we assume that everything
@@ -144,8 +136,8 @@ function openerp_restaurant_splitbill(instance, module){
this._super();
this.renderElement();
- var order = this.pos.get('selectedOrder');
- var neworder = new module.Order({
+ var order = this.pos.get_order();
+ var neworder = new module.Order({},{
pos: this.pos,
temporary: true,
});
@@ -159,10 +151,8 @@ function openerp_restaurant_splitbill(instance, module){
self.lineselect($el,order,neworder,splitlines,id);
});
- this.$('.paymentmethod').click(function(){
- var id = parseInt($(this).data('id'));
- var $el = $(this);
- self.pay($el,order,neworder,splitlines,id);
+ this.$('.paymentmethods .button').click(function(){
+ self.pay(order,neworder,splitlines);
});
},
});
@@ -180,7 +170,7 @@ function openerp_restaurant_splitbill(instance, module){
var splitbill = $(QWeb.render('SplitbillButton'));
splitbill.click(function(){
- if(self.pos.get('selectedOrder').get('orderLines').models.length > 0){
+ if(self.pos.get_order().get_orderlines().length > 0){
self.pos_widget.screen_selector.set_current_screen('splitbill');
}
});
diff --git a/addons/pos_restaurant/static/src/xml/floors.xml b/addons/pos_restaurant/static/src/xml/floors.xml
new file mode 100644
index 00000000000..dc8ba35eccb
--- /dev/null
+++ b/addons/pos_restaurant/static/src/xml/floors.xml
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+ at table
+
+
+
+
+
+
+
+ at table
+
+
+
+
+
+
+