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