[MERGE] trunk
[odoo/odoo.git] / addons / product / product.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 import math
23 import re
24
25 from _common import rounding
26
27 from openerp import tools
28 from openerp.osv import osv, fields
29 from openerp.tools.translate import _
30
31 import openerp.addons.decimal_precision as dp
32
33 def ean_checksum(eancode):
34     """returns the checksum of an ean string of length 13, returns -1 if the string has the wrong length"""
35     if len(eancode) <> 13:
36         return -1
37     oddsum=0
38     evensum=0
39     total=0
40     eanvalue=eancode
41     reversevalue = eanvalue[::-1]
42     finalean=reversevalue[1:]
43
44     for i in range(len(finalean)):
45         if i % 2 == 0:
46             oddsum += int(finalean[i])
47         else:
48             evensum += int(finalean[i])
49     total=(oddsum * 3) + evensum
50
51     check = int(10 - math.ceil(total % 10.0)) %10
52     return check
53
54 def check_ean(eancode):
55     """returns True if eancode is a valid ean13 string, or null"""
56     if not eancode:
57         return True
58     if len(eancode) <> 13:
59         return False
60     try:
61         int(eancode)
62     except:
63         return False
64     return ean_checksum(eancode) == int(eancode[-1])
65
66 def sanitize_ean13(ean13):
67     """Creates and returns a valid ean13 from an invalid one"""
68     if not ean13:
69         return "0000000000000"
70     ean13 = re.sub("[A-Za-z]","0",ean13);
71     ean13 = re.sub("[^0-9]","",ean13);
72     ean13 = ean13[:13]
73     if len(ean13) < 13:
74         ean13 = ean13 + '0' * (13-len(ean13))
75     return ean13[:-1] + str(ean_checksum(ean13))
76
77 #----------------------------------------------------------
78 # UOM
79 #----------------------------------------------------------
80
81 class product_uom_categ(osv.osv):
82     _name = 'product.uom.categ'
83     _description = 'Product uom categ'
84     _columns = {
85         'name': fields.char('Name', size=64, required=True, translate=True),
86     }
87
88 class product_uom(osv.osv):
89     _name = 'product.uom'
90     _description = 'Product Unit of Measure'
91
92     def _compute_factor_inv(self, factor):
93         return factor and (1.0 / factor) or 0.0
94
95     def _factor_inv(self, cursor, user, ids, name, arg, context=None):
96         res = {}
97         for uom in self.browse(cursor, user, ids, context=context):
98             res[uom.id] = self._compute_factor_inv(uom.factor)
99         return res
100
101     def _factor_inv_write(self, cursor, user, id, name, value, arg, context=None):
102         return self.write(cursor, user, id, {'factor': self._compute_factor_inv(value)}, context=context)
103
104     def name_create(self, cr, uid, name, context=None):
105         """ The UoM category and factor are required, so we'll have to add temporary values
106             for imported UoMs """
107         uom_categ = self.pool.get('product.uom.categ')
108         # look for the category based on the english name, i.e. no context on purpose!
109         # TODO: should find a way to have it translated but not created until actually used
110         categ_misc = 'Unsorted/Imported Units'
111         categ_id = uom_categ.search(cr, uid, [('name', '=', categ_misc)])
112         if categ_id:
113             categ_id = categ_id[0]
114         else:
115             categ_id, _ = uom_categ.name_create(cr, uid, categ_misc)
116         uom_id = self.create(cr, uid, {self._rec_name: name,
117                                        'category_id': categ_id,
118                                        'factor': 1})
119         return self.name_get(cr, uid, [uom_id], context=context)[0]
120
121     def create(self, cr, uid, data, context=None):
122         if 'factor_inv' in data:
123             if data['factor_inv'] <> 1:
124                 data['factor'] = self._compute_factor_inv(data['factor_inv'])
125             del(data['factor_inv'])
126         return super(product_uom, self).create(cr, uid, data, context)
127
128     _order = "name"
129     _columns = {
130         'name': fields.char('Unit of Measure', size=64, required=True, translate=True),
131         'category_id': fields.many2one('product.uom.categ', 'Category', required=True, ondelete='cascade',
132             help="Conversion between Units of Measure can only occur if they belong to the same category. The conversion will be made based on the ratios."),
133         'factor': fields.float('Ratio', required=True,digits=(12, 12),
134             help='How much bigger or smaller this unit is compared to the reference Unit of Measure for this category:\n'\
135                     '1 * (reference unit) = ratio * (this unit)'),
136         'factor_inv': fields.function(_factor_inv, digits=(12,12),
137             fnct_inv=_factor_inv_write,
138             string='Bigger Ratio',
139             help='How many times this Unit of Measure is bigger than the reference Unit of Measure in this category:\n'\
140                     '1 * (this unit) = ratio * (reference unit)', required=True),
141         'rounding': fields.float('Rounding Precision', digits_compute=dp.get_precision('Product Unit of Measure'), required=True,
142             help="The computed quantity will be a multiple of this value. "\
143                  "Use 1.0 for a Unit of Measure that cannot be further split, such as a piece."),
144         'active': fields.boolean('Active', help="By unchecking the active field you can disable a unit of measure without deleting it."),
145         'uom_type': fields.selection([('bigger','Bigger than the reference Unit of Measure'),
146                                       ('reference','Reference Unit of Measure for this category'),
147                                       ('smaller','Smaller than the reference Unit of Measure')],'Type', required=1),
148     }
149
150     _defaults = {
151         'active': 1,
152         'rounding': 0.01,
153         'uom_type': 'reference',
154     }
155
156     _sql_constraints = [
157         ('factor_gt_zero', 'CHECK (factor!=0)', 'The conversion ratio for a unit of measure cannot be 0!')
158     ]
159
160     def _compute_qty(self, cr, uid, from_uom_id, qty, to_uom_id=False, round=True):
161         if not from_uom_id or not qty or not to_uom_id:
162             return qty
163         uoms = self.browse(cr, uid, [from_uom_id, to_uom_id])
164         if uoms[0].id == from_uom_id:
165             from_unit, to_unit = uoms[0], uoms[-1]
166         else:
167             from_unit, to_unit = uoms[-1], uoms[0]
168         return self._compute_qty_obj(cr, uid, from_unit, qty, to_unit, round=round)
169
170     def _compute_qty_obj(self, cr, uid, from_unit, qty, to_unit, round=True, context=None):
171         if context is None:
172             context = {}
173         if from_unit.category_id.id <> to_unit.category_id.id:
174             if context.get('raise-exception', True):
175                 raise osv.except_osv(_('Error!'), _('Conversion from Product UoM %s to Default UoM %s is not possible as they both belong to different Category!.') % (from_unit.name,to_unit.name,))
176             else:
177                 return qty
178         amount = qty / from_unit.factor
179         if to_unit:
180             amount = amount * to_unit.factor
181             if round:
182                 amount = rounding(amount, to_unit.rounding)
183         return amount
184
185     def _compute_price(self, cr, uid, from_uom_id, price, to_uom_id=False):
186         if not from_uom_id or not price or not to_uom_id:
187             return price
188         uoms = self.browse(cr, uid, [from_uom_id, to_uom_id])
189         if uoms[0].id == from_uom_id:
190             from_unit, to_unit = uoms[0], uoms[-1]
191         else:
192             from_unit, to_unit = uoms[-1], uoms[0]
193         if from_unit.category_id.id <> to_unit.category_id.id:
194             return price
195         amount = price * from_unit.factor
196         if to_uom_id:
197             amount = amount / to_unit.factor
198         return amount
199
200     def onchange_type(self, cursor, user, ids, value):
201         if value == 'reference':
202             return {'value': {'factor': 1, 'factor_inv': 1}}
203         return {}
204
205     def write(self, cr, uid, ids, vals, context=None):
206         if 'category_id' in vals:
207             for uom in self.browse(cr, uid, ids, context=context):
208                 if uom.category_id.id != vals['category_id']:
209                     raise osv.except_osv(_('Warning!'),_("Cannot change the category of existing Unit of Measure '%s'.") % (uom.name,))
210         return super(product_uom, self).write(cr, uid, ids, vals, context=context)
211
212
213
214 class product_ul(osv.osv):
215     _name = "product.ul"
216     _description = "Shipping Unit"
217     _columns = {
218         'name' : fields.char('Name', size=64,select=True, required=True, translate=True),
219         'type' : fields.selection([('unit','Unit'),('pack','Pack'),('box', 'Box'), ('pallet', 'Pallet')], 'Type', required=True),
220     }
221
222
223 #----------------------------------------------------------
224 # Categories
225 #----------------------------------------------------------
226 class product_category(osv.osv):
227
228     def name_get(self, cr, uid, ids, context=None):
229         if isinstance(ids, (list, tuple)) and not len(ids):
230             return []
231         if isinstance(ids, (long, int)):
232             ids = [ids]
233         reads = self.read(cr, uid, ids, ['name','parent_id'], context=context)
234         res = []
235         for record in reads:
236             name = record['name']
237             if record['parent_id']:
238                 name = record['parent_id'][1]+' / '+name
239             res.append((record['id'], name))
240         return res
241
242     def name_search(self, cr, uid, name, args=None, operator='ilike', context=None, limit=100):
243         if not args:
244             args = []
245         if not context:
246             context = {}
247         if name:
248             # Be sure name_search is symetric to name_get
249             name = name.split(' / ')[-1]
250             ids = self.search(cr, uid, [('name', operator, name)] + args, limit=limit, context=context)
251         else:
252             ids = self.search(cr, uid, args, limit=limit, context=context)
253         return self.name_get(cr, uid, ids, context)
254
255     def _name_get_fnc(self, cr, uid, ids, prop, unknow_none, context=None):
256         res = self.name_get(cr, uid, ids, context=context)
257         return dict(res)
258
259     _name = "product.category"
260     _description = "Product Category"
261     _columns = {
262         'name': fields.char('Product Category Name', size=64, required=True, translate=True, select=True),
263         'complete_name': fields.function(_name_get_fnc, type="char", string='Name'),
264         'parent_id': fields.many2one('product.category','Parent Category', select=True, ondelete='cascade'),
265         'child_id': fields.one2many('product.category', 'parent_id', string='Child Categories'),
266         'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of product categories."),
267         'type': fields.selection([('view','View'), ('normal','Normal')], 'Category Type', help="A category of the view type is a virtual category that can be used as the parent of another category to create a hierarchical structure."),
268         'parent_left': fields.integer('Left Parent', select=1),
269         'parent_right': fields.integer('Right Parent', select=1),
270     }
271
272
273     _defaults = {
274         'type' : lambda *a : 'normal',
275     }
276
277     _parent_name = "parent_id"
278     _parent_store = True
279     _parent_order = 'sequence, name'
280     _order = 'parent_left'
281
282     def _check_recursion(self, cr, uid, ids, context=None):
283         level = 100
284         while len(ids):
285             cr.execute('select distinct parent_id from product_category where id IN %s',(tuple(ids),))
286             ids = filter(None, map(lambda x:x[0], cr.fetchall()))
287             if not level:
288                 return False
289             level -= 1
290         return True
291
292     _constraints = [
293         (_check_recursion, 'Error ! You cannot create recursive categories.', ['parent_id'])
294     ]
295
296
297
298 #----------------------------------------------------------
299 # Products
300 #----------------------------------------------------------
301 class product_template(osv.osv):
302     _name = "product.template"
303     _description = "Product Template"
304
305     _columns = {
306         'name': fields.char('Name', size=128, required=True, translate=True, select=True),
307         'product_manager': fields.many2one('res.users','Product Manager'),
308         'description': fields.text('Description',translate=True,
309             help="A precise description of the Product, used only for internal information purposes."),
310         'description_purchase': fields.text('Purchase Description',translate=True,
311             help="A description of the Product that you want to communicate to your suppliers. "
312                  "This description will be copied to every Purchase Order, Reception and Supplier Invoice/Refund."),
313         'description_sale': fields.text('Sale Description',translate=True,
314             help="A description of the Product that you want to communicate to your customers. "
315                  "This description will be copied to every Sale Order, Delivery Order and Customer Invoice/Refund"),
316         'type': fields.selection([('consu', 'Consumable'),('service','Service')], 'Product Type', required=True, help="Consumable are product where you don't manage stock, a service is a non-material product provided by a company or an individual."),        
317         'rental': fields.boolean('Can be Rent'),
318         'categ_id': fields.many2one('product.category','Category', required=True, change_default=True, domain="[('type','=','normal')]" ,help="Select category for the current product"),
319         'list_price': fields.float('Sale Price', digits_compute=dp.get_precision('Product Price'), help="Base price to compute the customer price. Sometimes called the catalog price."),
320         'standard_price': fields.property(type = 'float', digits_compute=dp.get_precision('Product Price'), 
321                                           help="Cost price of the product used for standard stock valuation in accounting and used as a base price on purchase orders.", 
322                                           groups="base.group_user", string="Cost"),
323         'volume': fields.float('Volume', help="The volume in m3."),
324         'weight': fields.float('Gross Weight', digits_compute=dp.get_precision('Stock Weight'), help="The gross weight in Kg."),
325         'weight_net': fields.float('Net Weight', digits_compute=dp.get_precision('Stock Weight'), help="The net weight in Kg."),
326         'warranty': fields.float('Warranty'),
327         'sale_ok': fields.boolean('Can be Sold', help="Specify if the product can be selected in a sales order line."),
328         'state': fields.selection([('',''),
329             ('draft', 'In Development'),
330             ('sellable','Normal'),
331             ('end','End of Lifecycle'),
332             ('obsolete','Obsolete')], 'Status'),
333         'uom_id': fields.many2one('product.uom', 'Unit of Measure', required=True, help="Default Unit of Measure used for all stock operation."),
334         'uom_po_id': fields.many2one('product.uom', 'Purchase Unit of Measure', required=True, help="Default Unit of Measure used for purchase orders. It must be in the same category than the default unit of measure."),
335         'uos_id' : fields.many2one('product.uom', 'Unit of Sale',
336             help='Specify a unit of measure here if invoicing is made in another unit of measure than inventory. Keep empty to use the default unit of measure.'),
337         'uos_coeff': fields.float('Unit of Measure -> UOS Coeff', digits_compute= dp.get_precision('Product UoS'),
338             help='Coefficient to convert default Unit of Measure to Unit of Sale\n'
339             ' uos = uom * coeff'),
340         'mes_type': fields.selection((('fixed', 'Fixed'), ('variable', 'Variable')), 'Measure Type'),
341         'seller_ids': fields.one2many('product.supplierinfo', 'product_id', 'Supplier'),
342         'company_id': fields.many2one('res.company', 'Company', select=1),
343     }
344
345     def _get_uom_id(self, cr, uid, *args):
346         cr.execute('select id from product_uom order by id limit 1')
347         res = cr.fetchone()
348         return res and res[0] or False
349
350     def _default_category(self, cr, uid, context=None):
351         if context is None:
352             context = {}
353         if 'categ_id' in context and context['categ_id']:
354             return context['categ_id']
355         md = self.pool.get('ir.model.data')
356         res = False
357         try:
358             res = md.get_object_reference(cr, uid, 'product', 'product_category_all')[1]
359         except ValueError:
360             res = False
361         return res
362
363     def onchange_uom(self, cursor, user, ids, uom_id, uom_po_id):
364         if uom_id:
365             return {'value': {'uom_po_id': uom_id}}
366         return {}
367
368     def write(self, cr, uid, ids, vals, context=None):
369         if 'uom_po_id' in vals:
370             new_uom = self.pool.get('product.uom').browse(cr, uid, vals['uom_po_id'], context=context)
371             for product in self.browse(cr, uid, ids, context=context):
372                 old_uom = product.uom_po_id
373                 if old_uom.category_id.id != new_uom.category_id.id:
374                     raise osv.except_osv(_('Unit of Measure categories Mismatch!'), _("New Unit of Measure '%s' must belong to same Unit of Measure category '%s' as of old Unit of Measure '%s'. If you need to change the unit of measure, you may deactivate this product from the 'Procurements' tab and create a new one.") % (new_uom.name, old_uom.category_id.name, old_uom.name,))
375         return super(product_template, self).write(cr, uid, ids, vals, context=context)
376
377     _defaults = {
378         'company_id': lambda s,cr,uid,c: s.pool.get('res.company')._company_default_get(cr, uid, 'product.template', context=c),
379         'list_price': 1,
380         'standard_price': 0.0,
381         'sale_ok': 1,        
382         'uom_id': _get_uom_id,
383         'uom_po_id': _get_uom_id,
384         'uos_coeff': 1.0,
385         'mes_type': 'fixed',
386         'categ_id' : _default_category,
387         'type' : 'consu',
388     }
389
390     def _check_uom(self, cursor, user, ids, context=None):
391         for product in self.browse(cursor, user, ids, context=context):
392             if product.uom_id.category_id.id <> product.uom_po_id.category_id.id:
393                 return False
394         return True
395
396     def _check_uos(self, cursor, user, ids, context=None):
397         for product in self.browse(cursor, user, ids, context=context):
398             if product.uos_id \
399                     and product.uos_id.category_id.id \
400                     == product.uom_id.category_id.id:
401                 return False
402         return True
403
404     _constraints = [
405         (_check_uom, 'Error: The default Unit of Measure and the purchase Unit of Measure must be in the same category.', ['uom_id']),
406     ]
407
408     def name_get(self, cr, user, ids, context=None):
409         if context is None:
410             context = {}
411         if 'partner_id' in context:
412             pass
413         return super(product_template, self).name_get(cr, user, ids, context)
414
415
416 class product_product(osv.osv):
417     def view_header_get(self, cr, uid, view_id, view_type, context=None):
418         if context is None:
419             context = {}
420         res = super(product_product, self).view_header_get(cr, uid, view_id, view_type, context)
421         if (context.get('categ_id', False)):
422             return _('Products: ')+self.pool.get('product.category').browse(cr, uid, context['categ_id'], context=context).name
423         return res
424
425     def _product_price(self, cr, uid, ids, name, arg, context=None):
426         res = {}
427         if context is None:
428             context = {}
429         quantity = context.get('quantity') or 1.0
430         pricelist = context.get('pricelist', False)
431         partner = context.get('partner', False)
432         if pricelist:
433             # Support context pricelists specified as display_name or ID for compatibility
434             if isinstance(pricelist, basestring):
435                 pricelist_ids = self.pool.get('product.pricelist').name_search(
436                     cr, uid, pricelist, operator='=', context=context, limit=1)
437                 pricelist = pricelist_ids[0][0] if pricelist_ids else pricelist
438             for id in ids:
439                 try:
440                     price = self.pool.get('product.pricelist').price_get(cr,uid,[pricelist], id, quantity, partner=partner, context=context)[pricelist]
441                 except:
442                     price = 0.0
443                 res[id] = price
444         for id in ids:
445             res.setdefault(id, 0.0)
446         return res
447
448     def _get_product_available_func(states, what):
449         def _product_available(self, cr, uid, ids, name, arg, context=None):
450             return {}.fromkeys(ids, 0.0)
451         return _product_available
452
453     _product_qty_available = _get_product_available_func(('done',), ('in', 'out'))
454     _product_virtual_available = _get_product_available_func(('confirmed','waiting','assigned','done'), ('in', 'out'))
455     _product_outgoing_qty = _get_product_available_func(('confirmed','waiting','assigned'), ('out',))
456     _product_incoming_qty = _get_product_available_func(('confirmed','waiting','assigned'), ('in',))
457
458     def _product_lst_price(self, cr, uid, ids, name, arg, context=None):
459         res = {}
460         product_uom_obj = self.pool.get('product.uom')
461         for id in ids:
462             res.setdefault(id, 0.0)
463         for product in self.browse(cr, uid, ids, context=context):
464             if 'uom' in context:
465                 uom = product.uos_id or product.uom_id
466                 res[product.id] = product_uom_obj._compute_price(cr, uid,
467                         uom.id, product.list_price, context['uom'])
468             else:
469                 res[product.id] = product.list_price
470             res[product.id] =  (res[product.id] or 0.0) * (product.price_margin or 1.0) + product.price_extra
471         return res
472
473     def _get_partner_code_name(self, cr, uid, ids, product, partner_id, context=None):
474         for supinfo in product.seller_ids:
475             if supinfo.name.id == partner_id:
476                 return {'code': supinfo.product_code or product.default_code, 'name': supinfo.product_name or product.name, 'variants': ''}
477         res = {'code': product.default_code, 'name': product.name, 'variants': product.variants}
478         return res
479
480     def _product_code(self, cr, uid, ids, name, arg, context=None):
481         res = {}
482         if context is None:
483             context = {}
484         for p in self.browse(cr, uid, ids, context=context):
485             res[p.id] = self._get_partner_code_name(cr, uid, [], p, context.get('partner_id', None), context=context)['code']
486         return res
487
488     def _product_partner_ref(self, cr, uid, ids, name, arg, context=None):
489         res = {}
490         if context is None:
491             context = {}
492         for p in self.browse(cr, uid, ids, context=context):
493             data = self._get_partner_code_name(cr, uid, [], p, context.get('partner_id', None), context=context)
494             if not data['variants']:
495                 data['variants'] = p.variants
496             if not data['code']:
497                 data['code'] = p.code
498             if not data['name']:
499                 data['name'] = p.name
500             res[p.id] = (data['code'] and ('['+data['code']+'] ') or '') + \
501                     (data['name'] or '') + (data['variants'] and (' - '+data['variants']) or '')
502         return res
503
504
505     def _get_main_product_supplier(self, cr, uid, product, context=None):
506         """Determines the main (best) product supplier for ``product``,
507         returning the corresponding ``supplierinfo`` record, or False
508         if none were found. The default strategy is to select the
509         supplier with the highest priority (i.e. smallest sequence).
510
511         :param browse_record product: product to supply
512         :rtype: product.supplierinfo browse_record or False
513         """
514         sellers = [(seller_info.sequence, seller_info)
515                        for seller_info in product.seller_ids or []
516                        if seller_info and isinstance(seller_info.sequence, (int, long))]
517         return sellers and sellers[0][1] or False
518
519     def _calc_seller(self, cr, uid, ids, fields, arg, context=None):
520         result = {}
521         for product in self.browse(cr, uid, ids, context=context):
522             main_supplier = self._get_main_product_supplier(cr, uid, product, context=context)
523             result[product.id] = {
524                 'seller_info_id': main_supplier and main_supplier.id or False,
525                 'seller_delay': main_supplier.delay if main_supplier else 1,
526                 'seller_qty': main_supplier and main_supplier.qty or 0.0,
527                 'seller_id': main_supplier and main_supplier.name.id or False
528             }
529         return result
530
531
532     def _get_image(self, cr, uid, ids, name, args, context=None):
533         result = dict.fromkeys(ids, False)
534         for obj in self.browse(cr, uid, ids, context=context):
535             result[obj.id] = tools.image_get_resized_images(obj.image, avoid_resize_medium=True)
536         return result
537
538     def _set_image(self, cr, uid, id, name, value, args, context=None):
539         return self.write(cr, uid, [id], {'image': tools.image_resize_image_big(value)}, context=context)
540
541     def _get_name_template_ids(self, cr, uid, ids, context=None):
542         result = set()
543         template_ids = self.pool.get('product.product').search(cr, uid, [('product_tmpl_id', 'in', ids)])
544         for el in template_ids:
545             result.add(el)
546         return list(result)
547
548     _defaults = {
549         'active': lambda *a: 1,
550         'price_extra': lambda *a: 0.0,
551         'price_margin': lambda *a: 1.0,
552         'color': 0,
553     }
554
555     _name = "product.product"
556     _description = "Product"
557     _table = "product_product"
558     _inherits = {'product.template': 'product_tmpl_id'}
559     _inherit = ['mail.thread']
560     _order = 'default_code,name_template'
561     _columns = {
562         'qty_available': fields.function(_product_qty_available, type='float', string='Quantity On Hand'),
563         'virtual_available': fields.function(_product_virtual_available, type='float', string='Quantity Available'),
564         'incoming_qty': fields.function(_product_incoming_qty, type='float', string='Incoming'),
565         'outgoing_qty': fields.function(_product_outgoing_qty, type='float', string='Outgoing'),
566         'price': fields.function(_product_price, type='float', string='Price', digits_compute=dp.get_precision('Product Price')),
567         'lst_price' : fields.function(_product_lst_price, type='float', string='Public Price', digits_compute=dp.get_precision('Product Price')),
568         'code': fields.function(_product_code, type='char', string='Internal Reference'),
569         'partner_ref' : fields.function(_product_partner_ref, type='char', string='Customer ref'),
570         'default_code' : fields.char('Internal Reference', size=64, select=True),
571         'active': fields.boolean('Active', help="If unchecked, it will allow you to hide the product without removing it."),
572         'variants': fields.char('Variants', size=64),
573         'product_tmpl_id': fields.many2one('product.template', 'Product Template', required=True, ondelete="cascade", select=True),
574         'ean13': fields.char('EAN13 Barcode', size=13, help="International Article Number used for product identification."),
575         'packaging' : fields.one2many('product.packaging', 'product_id', 'Logistical Units', help="Gives the different ways to package the same product. This has no impact on the picking order and is mainly used if you use the EDI module."),
576         'price_extra': fields.float('Variant Price Extra', digits_compute=dp.get_precision('Product Price')),
577         'price_margin': fields.float('Variant Price Margin', digits_compute=dp.get_precision('Product Price')),
578         'pricelist_id': fields.dummy(string='Pricelist', relation='product.pricelist', type='many2one'),
579         'name_template': fields.related('product_tmpl_id', 'name', string="Template Name", type='char', size=128, store={
580             'product.template': (_get_name_template_ids, ['name'], 10),
581             'product.product': (lambda self, cr, uid, ids, c=None: ids, [], 10),
582
583             }, select=True),
584         'color': fields.integer('Color Index'),
585         # image: all image fields are base64 encoded and PIL-supported
586         'image': fields.binary("Image",
587             help="This field holds the image used as image for the product, limited to 1024x1024px."),
588         'image_medium': fields.function(_get_image, fnct_inv=_set_image,
589             string="Medium-sized image", type="binary", multi="_get_image",
590             store={
591                 'product.product': (lambda self, cr, uid, ids, c={}: ids, ['image'], 10),
592             },
593             help="Medium-sized image of the product. It is automatically "\
594                  "resized as a 128x128px image, with aspect ratio preserved, "\
595                  "only when the image exceeds one of those sizes. Use this field in form views or some kanban views."),
596         'image_small': fields.function(_get_image, fnct_inv=_set_image,
597             string="Small-sized image", type="binary", multi="_get_image",
598             store={
599                 'product.product': (lambda self, cr, uid, ids, c={}: ids, ['image'], 10),
600             },
601             help="Small-sized image of the product. It is automatically "\
602                  "resized as a 64x64px image, with aspect ratio preserved. "\
603                  "Use this field anywhere a small image is required."),
604         'seller_info_id': fields.function(_calc_seller, type='many2one', relation="product.supplierinfo", string="Supplier Info", multi="seller_info"),
605         'seller_delay': fields.function(_calc_seller, type='integer', string='Supplier Lead Time', multi="seller_info", help="This is the average delay in days between the purchase order confirmation and the reception of goods for this product and for the default supplier. It is used by the scheduler to order requests based on reordering delays."),
606         'seller_qty': fields.function(_calc_seller, type='float', string='Supplier Quantity', multi="seller_info", help="This is minimum quantity to purchase from Main Supplier."),
607         'seller_id': fields.function(_calc_seller, type='many2one', relation="res.partner", string='Main Supplier', help="Main Supplier who has highest priority in Supplier List.", multi="seller_info"),
608     }
609     def unlink(self, cr, uid, ids, context=None):
610         unlink_ids = []
611         unlink_product_tmpl_ids = []
612         for product in self.browse(cr, uid, ids, context=context):
613             tmpl_id = product.product_tmpl_id.id
614             # Check if the product is last product of this template
615             other_product_ids = self.search(cr, uid, [('product_tmpl_id', '=', tmpl_id), ('id', '!=', product.id)], context=context)
616             if not other_product_ids:
617                 unlink_product_tmpl_ids.append(tmpl_id)
618             unlink_ids.append(product.id)
619         res = super(product_product, self).unlink(cr, uid, unlink_ids, context=context)
620         # delete templates after calling super, as deleting template could lead to deleting
621         # products due to ondelete='cascade'
622         self.pool.get('product.template').unlink(cr, uid, unlink_product_tmpl_ids, context=context)
623         return res
624
625     def onchange_uom(self, cursor, user, ids, uom_id, uom_po_id):
626         if uom_id and uom_po_id:
627             uom_obj=self.pool.get('product.uom')
628             uom=uom_obj.browse(cursor,user,[uom_id])[0]
629             uom_po=uom_obj.browse(cursor,user,[uom_po_id])[0]
630             if uom.category_id.id != uom_po.category_id.id:
631                 return {'value': {'uom_po_id': uom_id}}
632         return False
633
634     def _check_ean_key(self, cr, uid, ids, context=None):
635         for product in self.read(cr, uid, ids, ['ean13'], context=context):
636             res = check_ean(product['ean13'])
637         return res
638
639
640     _constraints = [(_check_ean_key, 'You provided an invalid "EAN13 Barcode" reference. You may use the "Internal Reference" field instead.', ['ean13'])]
641
642     def on_order(self, cr, uid, ids, orderline, quantity):
643         pass
644
645     def name_get(self, cr, user, ids, context=None):
646         if context is None:
647             context = {}
648         if isinstance(ids, (int, long)):
649             ids = [ids]
650         if not len(ids):
651             return []
652         def _name_get(d):
653             name = d.get('name','')
654             code = d.get('default_code',False)
655             if code:
656                 name = '[%s] %s' % (code,name)
657             if d.get('variants'):
658                 name = name + ' - %s' % (d['variants'],)
659             return (d['id'], name)
660
661         partner_id = context.get('partner_id', False)
662
663         result = []
664         for product in self.browse(cr, user, ids, context=context):
665             sellers = filter(lambda x: x.name.id == partner_id, product.seller_ids)
666             if sellers:
667                 for s in sellers:
668                     mydict = {
669                               'id': product.id,
670                               'name': s.product_name or product.name,
671                               'default_code': s.product_code or product.default_code,
672                               'variants': product.variants
673                               }
674                     result.append(_name_get(mydict))
675             else:
676                 mydict = {
677                           'id': product.id,
678                           'name': product.name,
679                           'default_code': product.default_code,
680                           'variants': product.variants
681                           }
682                 result.append(_name_get(mydict))
683         return result
684
685     def name_search(self, cr, user, name='', args=None, operator='ilike', context=None, limit=100):
686         if not args:
687             args = []
688         if name:
689             ids = self.search(cr, user, [('default_code','=',name)]+ args, limit=limit, context=context)
690             if not ids:
691                 ids = self.search(cr, user, [('ean13','=',name)]+ args, limit=limit, context=context)
692             if not ids:
693                 # Do not merge the 2 next lines into one single search, SQL search performance would be abysmal
694                 # on a database with thousands of matching products, due to the huge merge+unique needed for the
695                 # OR operator (and given the fact that the 'name' lookup results come from the ir.translation table
696                 # Performing a quick memory merge of ids in Python will give much better performance
697                 ids = set()
698                 ids.update(self.search(cr, user, args + [('default_code',operator,name)], limit=limit, context=context))
699                 if not limit or len(ids) < limit:
700                     # we may underrun the limit because of dupes in the results, that's fine
701                     ids.update(self.search(cr, user, args + [('name',operator,name)], limit=(limit and (limit-len(ids)) or False) , context=context))
702                 ids = list(ids)
703             if not ids:
704                 ptrn = re.compile('(\[(.*?)\])')
705                 res = ptrn.search(name)
706                 if res:
707                     ids = self.search(cr, user, [('default_code','=', res.group(2))] + args, limit=limit, context=context)
708         else:
709             ids = self.search(cr, user, args, limit=limit, context=context)
710         result = self.name_get(cr, user, ids, context=context)
711         return result
712
713     #
714     # Could be overrided for variants matrices prices
715     #
716     def price_get(self, cr, uid, ids, ptype='list_price', context=None):
717         if context is None:
718             context = {}
719
720         if 'currency_id' in context:
721             pricetype_obj = self.pool.get('product.price.type')
722             price_type_id = pricetype_obj.search(cr, uid, [('field','=',ptype)])[0]
723             price_type_currency_id = pricetype_obj.browse(cr,uid,price_type_id).currency_id.id
724
725         res = {}
726         product_uom_obj = self.pool.get('product.uom')
727         for product in self.browse(cr, uid, ids, context=context):
728             res[product.id] = product[ptype] or 0.0
729             if ptype == 'list_price':
730                 res[product.id] = (res[product.id] * (product.price_margin or 1.0)) + \
731                         product.price_extra
732             if 'uom' in context:
733                 uom = product.uom_id or product.uos_id
734                 res[product.id] = product_uom_obj._compute_price(cr, uid,
735                         uom.id, res[product.id], context['uom'])
736             # Convert from price_type currency to asked one
737             if 'currency_id' in context:
738                 # Take the price_type currency from the product field
739                 # This is right cause a field cannot be in more than one currency
740                 res[product.id] = self.pool.get('res.currency').compute(cr, uid, price_type_currency_id,
741                     context['currency_id'], res[product.id],context=context)
742
743         return res
744
745     def copy(self, cr, uid, id, default=None, context=None):
746         if context is None:
747             context={}
748
749         if not default:
750             default = {}
751
752         # Craft our own `<name> (copy)` in en_US (self.copy_translation()
753         # will do the other languages).
754         context_wo_lang = context.copy()
755         context_wo_lang.pop('lang', None)
756         product = self.read(cr, uid, id, ['name'], context=context_wo_lang)
757         default = default.copy()
758         default.update(name=_("%s (copy)") % (product['name']))
759
760         if context.get('variant',False):
761             fields = ['product_tmpl_id', 'active', 'variants', 'default_code',
762                     'price_margin', 'price_extra']
763             data = self.read(cr, uid, id, fields=fields, context=context)
764             for f in fields:
765                 if f in default:
766                     data[f] = default[f]
767             data['product_tmpl_id'] = data.get('product_tmpl_id', False) \
768                     and data['product_tmpl_id'][0]
769             del data['id']
770             return self.create(cr, uid, data)
771         else:
772             return super(product_product, self).copy(cr, uid, id, default=default,
773                     context=context)
774
775     def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
776         if context is None:
777             context = {}
778         if context and context.get('search_default_categ_id', False):
779             args.append((('categ_id', 'child_of', context['search_default_categ_id'])))
780         return super(product_product, self).search(cr, uid, args, offset=offset, limit=limit, order=order, context=context, count=count)
781
782
783 class product_packaging(osv.osv):
784     _name = "product.packaging"
785     _description = "Packaging"
786     _rec_name = 'ean'
787     _order = 'sequence'
788     _columns = {
789         'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of packaging."),
790         'name' : fields.text('Description', size=64),
791         'qty' : fields.float('Quantity by Package',
792             help="The total number of products you can put by pallet or box."),
793         'ul' : fields.many2one('product.ul', 'Type of Package', required=True),
794         'ul_qty' : fields.integer('Package by layer', help='The number of packages by layer'),
795         'rows' : fields.integer('Number of Layers', required=True,
796             help='The number of layers on a pallet or box'),
797         'product_id' : fields.many2one('product.product', 'Product', select=1, ondelete='cascade', required=True),
798         'ean' : fields.char('EAN', size=14,
799             help="The EAN code of the package unit."),
800         'code' : fields.char('Code', size=14,
801             help="The code of the transport unit."),
802         'weight': fields.float('Total Package Weight',
803             help='The weight of a full package, pallet or box.'),
804         'weight_ul': fields.float('Empty Package Weight'),
805         'height': fields.float('Height', help='The height of the package'),
806         'width': fields.float('Width', help='The width of the package'),
807         'length': fields.float('Length', help='The length of the package'),
808     }
809
810
811     def _check_ean_key(self, cr, uid, ids, context=None):
812         for pack in self.browse(cr, uid, ids, context=context):
813             res = check_ean(pack.ean)
814         return res
815
816     _constraints = [(_check_ean_key, 'Error: Invalid ean code', ['ean'])]
817
818     def name_get(self, cr, uid, ids, context=None):
819         if not len(ids):
820             return []
821         res = []
822         for pckg in self.browse(cr, uid, ids, context=context):
823             p_name = pckg.ean and '[' + pckg.ean + '] ' or ''
824             p_name += pckg.ul.name
825             res.append((pckg.id,p_name))
826         return res
827
828     def _get_1st_ul(self, cr, uid, context=None):
829         cr.execute('select id from product_ul order by id asc limit 1')
830         res = cr.fetchone()
831         return (res and res[0]) or False
832
833     _defaults = {
834         'rows' : lambda *a : 3,
835         'sequence' : lambda *a : 1,
836         'ul' : _get_1st_ul,
837     }
838
839     def checksum(ean):
840         salt = '31' * 6 + '3'
841         sum = 0
842         for ean_part, salt_part in zip(ean, salt):
843             sum += int(ean_part) * int(salt_part)
844         return (10 - (sum % 10)) % 10
845     checksum = staticmethod(checksum)
846
847
848
849 class product_supplierinfo(osv.osv):
850     _name = "product.supplierinfo"
851     _description = "Information about a product supplier"
852     def _calc_qty(self, cr, uid, ids, fields, arg, context=None):
853         result = {}
854         product_uom_pool = self.pool.get('product.uom')
855         for supplier_info in self.browse(cr, uid, ids, context=context):
856             for field in fields:
857                 result[supplier_info.id] = {field:False}
858             qty = supplier_info.min_qty
859             result[supplier_info.id]['qty'] = qty
860         return result
861
862     _columns = {
863         'name' : fields.many2one('res.partner', 'Supplier', required=True,domain = [('supplier','=',True)], ondelete='cascade', help="Supplier of this product"),
864         'product_name': fields.char('Supplier Product Name', size=128, help="This supplier's product name will be used when printing a request for quotation. Keep empty to use the internal one."),
865         'product_code': fields.char('Supplier Product Code', size=64, help="This supplier's product code will be used when printing a request for quotation. Keep empty to use the internal one."),
866         'sequence' : fields.integer('Sequence', help="Assigns the priority to the list of product supplier."),
867         'product_uom': fields.related('product_id', 'uom_po_id', type='many2one', relation='product.uom', string="Supplier Unit of Measure", readonly="1", help="This comes from the product form."),
868         'min_qty': fields.float('Minimal Quantity', required=True, help="The minimal quantity to purchase to this supplier, expressed in the supplier Product Unit of Measure if not empty, in the default unit of measure of the product otherwise."),
869         'qty': fields.function(_calc_qty, store=True, type='float', string='Quantity', multi="qty", help="This is a quantity which is converted into Default Unit of Measure."),
870         'product_id' : fields.many2one('product.template', 'Product', required=True, ondelete='cascade', select=True),
871         'delay' : fields.integer('Delivery Lead Time', required=True, help="Lead time in days between the confirmation of the purchase order and the reception of the products in your warehouse. Used by the scheduler for automatic computation of the purchase order planning."),
872         'pricelist_ids': fields.one2many('pricelist.partnerinfo', 'suppinfo_id', 'Supplier Pricelist'),
873         'company_id':fields.many2one('res.company','Company',select=1),
874     }
875     _defaults = {
876         'qty': lambda *a: 0.0,
877         'sequence': lambda *a: 1,
878         'delay': lambda *a: 1,
879         'company_id': lambda self,cr,uid,c: self.pool.get('res.company')._company_default_get(cr, uid, 'product.supplierinfo', context=c),
880     }
881     def price_get(self, cr, uid, supplier_ids, product_id, product_qty=1, context=None):
882         """
883         Calculate price from supplier pricelist.
884         @param supplier_ids: Ids of res.partner object.
885         @param product_id: Id of product.
886         @param product_qty: specify quantity to purchase.
887         """
888         if type(supplier_ids) in (int,long,):
889             supplier_ids = [supplier_ids]
890         res = {}
891         product_pool = self.pool.get('product.product')
892         partner_pool = self.pool.get('res.partner')
893         pricelist_pool = self.pool.get('product.pricelist')
894         currency_pool = self.pool.get('res.currency')
895         currency_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id.currency_id.id
896         for supplier in partner_pool.browse(cr, uid, supplier_ids, context=context):
897             # Compute price from standard price of product
898             price = product_pool.price_get(cr, uid, [product_id], 'standard_price', context=context)[product_id]
899
900             # Compute price from Purchase pricelist of supplier
901             pricelist_id = supplier.property_product_pricelist_purchase.id
902             if pricelist_id:
903                 price = pricelist_pool.price_get(cr, uid, [pricelist_id], product_id, product_qty, context=context).setdefault(pricelist_id, 0)
904                 price = currency_pool.compute(cr, uid, pricelist_pool.browse(cr, uid, pricelist_id).currency_id.id, currency_id, price)
905
906             # Compute price from supplier pricelist which are in Supplier Information
907             supplier_info_ids = self.search(cr, uid, [('name','=',supplier.id),('product_id','=',product_id)])
908             if supplier_info_ids:
909                 cr.execute('SELECT * ' \
910                     'FROM pricelist_partnerinfo ' \
911                     'WHERE suppinfo_id IN %s' \
912                     'AND min_quantity <= %s ' \
913                     'ORDER BY min_quantity DESC LIMIT 1', (tuple(supplier_info_ids),product_qty,))
914                 res2 = cr.dictfetchone()
915                 if res2:
916                     price = res2['price']
917             res[supplier.id] = price
918         return res
919     _order = 'sequence'
920
921
922 class pricelist_partnerinfo(osv.osv):
923     _name = 'pricelist.partnerinfo'
924     _columns = {
925         'name': fields.char('Description', size=64),
926         'suppinfo_id': fields.many2one('product.supplierinfo', 'Partner Information', required=True, ondelete='cascade'),
927         'min_quantity': fields.float('Quantity', required=True, help="The minimal quantity to trigger this rule, expressed in the supplier Unit of Measure if any or in the default Unit of Measure of the product otherrwise."),
928         'price': fields.float('Unit Price', required=True, digits_compute=dp.get_precision('Product Price'), help="This price will be considered as a price for the supplier Unit of Measure if any or the default Unit of Measure of the product otherwise"),
929     }
930     _order = 'min_quantity asc'
931
932 class res_currency(osv.osv):
933     _inherit = 'res.currency'
934
935     def _check_main_currency_rounding(self, cr, uid, ids, context=None):
936         cr.execute('SELECT digits FROM decimal_precision WHERE name like %s',('Account',))
937         digits = cr.fetchone()
938         if digits and len(digits):
939             digits = digits[0]
940             main_currency = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id.currency_id
941             for currency_id in ids:
942                 if currency_id == main_currency.id:
943                     if main_currency.rounding < 10 ** -digits:
944                         return False
945         return True
946
947     _constraints = [
948         (_check_main_currency_rounding, 'Error! You cannot define a rounding factor for the company\'s main currency that is smaller than the decimal precision of \'Account\'.', ['rounding']),
949     ]
950
951 class decimal_precision(osv.osv):
952     _inherit = 'decimal.precision'
953
954     def _check_main_currency_rounding(self, cr, uid, ids, context=None):
955         cr.execute('SELECT id, digits FROM decimal_precision WHERE name like %s',('Account',))
956         res = cr.fetchone()
957         if res and len(res):
958             account_precision_id, digits = res
959             main_currency = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id.currency_id
960             for decimal_precision in ids:
961                 if decimal_precision == account_precision_id:
962                     if main_currency.rounding < 10 ** -digits:
963                         return False
964         return True
965
966     _constraints = [
967         (_check_main_currency_rounding, 'Error! You cannot define the decimal precision of \'Account\' as greater than the rounding factor of the company\'s main currency', ['digits']),
968     ]
969
970 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: