[FIX] product.product: onchange -> on_change.
[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 from osv import osv, fields
23 import decimal_precision as dp
24
25 import math
26 from _common import rounding
27 import re
28 import tools
29 from tools.translate import _
30
31 def is_pair(x):
32     return not x%2
33
34 def check_ean(eancode):
35     if not eancode:
36         return True
37     if len(eancode) <> 13:
38         return False
39     try:
40         int(eancode)
41     except:
42         return False
43     oddsum=0
44     evensum=0
45     total=0
46     eanvalue=eancode
47     reversevalue = eanvalue[::-1]
48     finalean=reversevalue[1:]
49
50     for i in range(len(finalean)):
51         if is_pair(i):
52             oddsum += int(finalean[i])
53         else:
54             evensum += int(finalean[i])
55     total=(oddsum * 3) + evensum
56
57     check = int(10 - math.ceil(total % 10.0)) %10
58
59     if check != int(eancode[-1]):
60         return False
61     return True
62 #----------------------------------------------------------
63 # UOM
64 #----------------------------------------------------------
65
66 class product_uom_categ(osv.osv):
67     _name = 'product.uom.categ'
68     _description = 'Product uom categ'
69     _columns = {
70         'name': fields.char('Name', size=64, required=True, translate=True),
71     }
72 product_uom_categ()
73
74 class product_uom(osv.osv):
75     _name = 'product.uom'
76     _description = 'Product Unit of Measure'
77
78     def _compute_factor_inv(self, factor):
79         return factor and (1.0 / factor) or 0.0
80
81     def _factor_inv(self, cursor, user, ids, name, arg, context=None):
82         res = {}
83         for uom in self.browse(cursor, user, ids, context=context):
84             res[uom.id] = self._compute_factor_inv(uom.factor)
85         return res
86
87     def _factor_inv_write(self, cursor, user, id, name, value, arg, context=None):
88         return self.write(cursor, user, id, {'factor': self._compute_factor_inv(value)}, context=context)
89
90     def create(self, cr, uid, data, context=None):
91         if 'factor_inv' in data:
92             if data['factor_inv'] <> 1:
93                 data['factor'] = self._compute_factor_inv(data['factor_inv'])
94             del(data['factor_inv'])
95         return super(product_uom, self).create(cr, uid, data, context)
96
97     _order = "name"
98     _columns = {
99         'name': fields.char('Name', size=64, required=True, translate=True),
100         'category_id': fields.many2one('product.uom.categ', 'Unit of Measure Category', required=True, ondelete='cascade',
101             help="Quantity conversions may happen automatically between Units of Measure in the same category, according to their respective ratios."),
102         'factor': fields.float('Ratio', required=True,digits=(12, 12),
103             help='How many times this Unit of Measure is smaller than the reference Unit of Measure in this category:\n'\
104                     '1 * (reference unit) = ratio * (this unit)'),
105         'factor_inv': fields.function(_factor_inv, digits=(12,12),
106             fnct_inv=_factor_inv_write,
107             string='Ratio',
108             help='How many times this Unit of Measure is bigger than the reference Unit of Measure in this category:\n'\
109                     '1 * (this unit) = ratio * (reference unit)', required=True),
110         'rounding': fields.float('Rounding Precision', digits_compute=dp.get_precision('Product Unit of Measure'), required=True,
111             help="The computed quantity will be a multiple of this value. "\
112                  "Use 1.0 for a Unit of Measure that cannot be further split, such as a piece."),
113         'active': fields.boolean('Active', help="By unchecking the active field you can disable a unit of measure without deleting it."),
114         'uom_type': fields.selection([('bigger','Bigger than the reference Unit of Measure'),
115                                       ('reference','Reference Unit of Measure for this category'),
116                                       ('smaller','Smaller than the reference Unit of Measure')],'Unit of Measure Type', required=1),
117     }
118
119     _defaults = {
120         'active': 1,
121         'rounding': 0.01,
122         'uom_type': 'reference',
123     }
124
125     _sql_constraints = [
126         ('factor_gt_zero', 'CHECK (factor!=0)', 'The conversion ratio for a unit of measure cannot be 0!')
127     ]
128
129     def _compute_qty(self, cr, uid, from_uom_id, qty, to_uom_id=False):
130         if not from_uom_id or not qty or not to_uom_id:
131             return qty
132         uoms = self.browse(cr, uid, [from_uom_id, to_uom_id])
133         if uoms[0].id == from_uom_id:
134             from_unit, to_unit = uoms[0], uoms[-1]
135         else:
136             from_unit, to_unit = uoms[-1], uoms[0]
137         return self._compute_qty_obj(cr, uid, from_unit, qty, to_unit)
138
139     def _compute_qty_obj(self, cr, uid, from_unit, qty, to_unit, context=None):
140         if context is None:
141             context = {}
142         if from_unit.category_id.id <> to_unit.category_id.id:
143             if context.get('raise-exception', True):
144                 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,))
145             else:
146                 return qty
147         amount = qty / from_unit.factor
148         if to_unit:
149             amount = rounding(amount * to_unit.factor, to_unit.rounding)
150         return amount
151
152     def _compute_price(self, cr, uid, from_uom_id, price, to_uom_id=False):
153         if not from_uom_id or not price or not to_uom_id:
154             return price
155         uoms = self.browse(cr, uid, [from_uom_id, to_uom_id])
156         if uoms[0].id == from_uom_id:
157             from_unit, to_unit = uoms[0], uoms[-1]
158         else:
159             from_unit, to_unit = uoms[-1], uoms[0]
160         if from_unit.category_id.id <> to_unit.category_id.id:
161             return price
162         amount = price * from_unit.factor
163         if to_uom_id:
164             amount = amount / to_unit.factor
165         return amount
166
167     def onchange_type(self, cursor, user, ids, value):
168         if value == 'reference':
169             return {'value': {'factor': 1, 'factor_inv': 1}}
170         return {}
171     
172     def write(self, cr, uid, ids, vals, context=None):
173         if 'category_id' in vals:
174             for uom in self.browse(cr, uid, ids, context=context):
175                 if uom.category_id.id != vals['category_id']:
176                     raise osv.except_osv(_('Warning'),_("Cannot change the category of existing Unit of Measure '%s'.") % (uom.name,))
177         return super(product_uom, self).write(cr, uid, ids, vals, context=context)
178
179 product_uom()
180
181
182 class product_ul(osv.osv):
183     _name = "product.ul"
184     _description = "Shipping Unit"
185     _columns = {
186         'name' : fields.char('Name', size=64,select=True, required=True, translate=True),
187         'type' : fields.selection([('unit','Unit'),('pack','Pack'),('box', 'Box'), ('pallet', 'Pallet')], 'Type', required=True),
188     }
189 product_ul()
190
191
192 #----------------------------------------------------------
193 # Categories
194 #----------------------------------------------------------
195 class product_category(osv.osv):
196
197     def name_get(self, cr, uid, ids, context=None):
198         if not len(ids):
199             return []
200         reads = self.read(cr, uid, ids, ['name','parent_id'], context=context)
201         res = []
202         for record in reads:
203             name = record['name']
204             if record['parent_id']:
205                 name = record['parent_id'][1]+' / '+name
206             res.append((record['id'], name))
207         return res
208
209     def _name_get_fnc(self, cr, uid, ids, prop, unknow_none, context=None):
210         res = self.name_get(cr, uid, ids, context=context)
211         return dict(res)
212
213     _name = "product.category"
214     _description = "Product Category"
215     _columns = {
216         'name': fields.char('Name', size=64, required=True, translate=True, select=True),
217         'complete_name': fields.function(_name_get_fnc, type="char", string='Name'),
218         'parent_id': fields.many2one('product.category','Parent Category', select=True, ondelete='cascade'),
219         'child_id': fields.one2many('product.category', 'parent_id', string='Child Categories'),
220         'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of product categories."),
221         'type': fields.selection([('view','View'), ('normal','Normal')], 'Category Type'),
222         'parent_left': fields.integer('Left Parent', select=1),
223         'parent_right': fields.integer('Right Parent', select=1),
224     }
225
226
227     _defaults = {
228         'type' : lambda *a : 'normal',
229     }
230
231     _parent_name = "parent_id"
232     _parent_store = True
233     _parent_order = 'sequence, name'
234     _order = 'parent_left'
235     
236     def _check_recursion(self, cr, uid, ids, context=None):
237         level = 100
238         while len(ids):
239             cr.execute('select distinct parent_id from product_category where id IN %s',(tuple(ids),))
240             ids = filter(None, map(lambda x:x[0], cr.fetchall()))
241             if not level:
242                 return False
243             level -= 1
244         return True
245
246     _constraints = [
247         (_check_recursion, 'Error ! You cannot create recursive categories.', ['parent_id'])
248     ]
249     def child_get(self, cr, uid, ids):
250         return [ids]
251
252 product_category()
253
254
255 #----------------------------------------------------------
256 # Products
257 #----------------------------------------------------------
258 class product_template(osv.osv):
259     _name = "product.template"
260     _description = "Product Template"
261
262     def _get_main_product_supplier(self, cr, uid, product, context=None):
263         """Determines the main (best) product supplier for ``product``,
264         returning the corresponding ``supplierinfo`` record, or False
265         if none were found. The default strategy is to select the
266         supplier with the highest priority (i.e. smallest sequence).
267
268         :param browse_record product: product to supply
269         :rtype: product.supplierinfo browse_record or False
270         """
271         sellers = [(seller_info.sequence, seller_info)
272                        for seller_info in product.seller_ids or []
273                        if seller_info and isinstance(seller_info.sequence, (int, long))]
274         return sellers and sellers[0][1] or False
275
276     def _calc_seller(self, cr, uid, ids, fields, arg, context=None):
277         result = {}
278         for product in self.browse(cr, uid, ids, context=context):
279             main_supplier = self._get_main_product_supplier(cr, uid, product, context=context)
280             result[product.id] = {
281                 'seller_info_id': main_supplier and main_supplier.id or False,
282                 'seller_delay': main_supplier and main_supplier.delay or 1,
283                 'seller_qty': main_supplier and main_supplier.qty or 0.0,
284                 'seller_id': main_supplier and main_supplier.name.id or False
285             }
286         return result
287
288     _columns = {
289         'name': fields.char('Name', size=128, required=True, translate=True, select=True),
290         'product_manager': fields.many2one('res.users','Product Manager',help="This is use as task responsible"),
291         'description': fields.text('Description',translate=True),
292         'description_purchase': fields.text('Purchase Description',translate=True),
293         'description_sale': fields.text('Sale Description',translate=True),
294         'type': fields.selection([('product','Stockable Product'),('consu', 'Consumable'),('service','Service')], 'Product Type', required=True, help="Will change the way procurements are processed. Consumable are product where you don't manage stock."),
295         'supply_method': fields.selection([('produce','Manufacture'),('buy','Buy')], 'Supply Method', required=True, help="Produce will generate production order or tasks, according to the product type. Buy will trigger purchase orders when requested."),
296         'sale_delay': fields.float('Customer Lead Time', help="This is the average delay in days between the confirmation of the customer order and the delivery of the finished products. It's the time you promise to your customers."),
297         'produce_delay': fields.float('Manufacturing Lead Time', help="Average delay in days to produce this product. This is only for the production order and, if it is a multi-level bill of material, it's only for the level of this product. Different lead times will be summed for all levels and purchase orders."),
298         'procure_method': fields.selection([('make_to_stock','Make to Stock'),('make_to_order','Make to Order')], 'Procurement Method', required=True, help="'Make to Stock': When needed, take from the stock or wait until re-supplying. 'Make to Order': When needed, purchase or produce for the procurement request."),
299         'rental': fields.boolean('Can be Rent'),
300         'categ_id': fields.many2one('product.category','Category', required=True, change_default=True, domain="[('type','=','normal')]" ,help="Select category for the current product"),
301         'list_price': fields.float('Sale Price', digits_compute=dp.get_precision('Sale Price'), help="Base price for computing the customer price. Sometimes called the catalog price."),
302         'standard_price': fields.float('Cost Price', required=True, digits_compute=dp.get_precision('Purchase Price'), help="Product's cost for accounting stock valuation. It is the base price for the supplier price."),
303         'volume': fields.float('Volume', help="The volume in m3."),
304         'weight': fields.float('Gross Weight', digits_compute=dp.get_precision('Stock Weight'), help="The gross weight in Kg."),
305         'weight_net': fields.float('Net Weight', digits_compute=dp.get_precision('Stock Weight'), help="The net weight in Kg."),
306         'cost_method': fields.selection([('standard','Standard Price'), ('average','Average Price')], 'Costing Method', required=True,
307             help="Standard Price: the cost price is fixed and recomputed periodically (usually at the end of the year), Average Price: the cost price is recomputed at each reception of products."),
308         'warranty': fields.float('Warranty (months)'),
309         'sale_ok': fields.boolean('Can be Sold', help="Determines if the product can be visible in the list of product within a selection from a sale order line."),
310         'purchase_ok': fields.boolean('Can be Purchased', help="Determine if the product is visible in the list of products within a selection from a purchase order line."),
311         'state': fields.selection([('',''),
312             ('draft', 'In Development'),
313             ('sellable','Normal'),
314             ('end','End of Lifecycle'),
315             ('obsolete','Obsolete')], 'Status', help="Tells the user if he can use the product or not."),
316         'uom_id': fields.many2one('product.uom', 'Default Unit of Measure', required=True, help="Default Unit of Measure used for all stock operation."),
317         '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."),
318         'uos_id' : fields.many2one('product.uom', 'Unit of Sale',
319             help='Used by companies that manage two units of measure: invoicing and inventory management. For example, in food industries, you will manage a stock of ham but invoice in Kg. Keep empty to use the default Unit of Measure.'),
320         'uos_coeff': fields.float('Unit of Measure -> UOS Coeff', digits_compute= dp.get_precision('Product UoS'),
321             help='Coefficient to convert Unit of Measure to UOS\n'
322             ' uos = uom * coeff'),
323         'mes_type': fields.selection((('fixed', 'Fixed'), ('variable', 'Variable')), 'Measure Type', required=True),
324         'seller_info_id': fields.function(_calc_seller, type='many2one', relation="product.supplierinfo", multi="seller_info"),
325         '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."),
326         'seller_qty': fields.function(_calc_seller, type='float', string='Supplier Quantity', multi="seller_info", help="This is minimum quantity to purchase from Main Supplier."),
327         '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"),
328         'seller_ids': fields.one2many('product.supplierinfo', 'product_id', 'Partners'),
329         'loc_rack': fields.char('Rack', size=16),
330         'loc_row': fields.char('Row', size=16),
331         'loc_case': fields.char('Case', size=16),
332         'company_id': fields.many2one('res.company', 'Company', select=1),
333     }
334
335     def _get_uom_id(self, cr, uid, *args):
336         cr.execute('select id from product_uom order by id limit 1')
337         res = cr.fetchone()
338         return res and res[0] or False
339
340     def _default_category(self, cr, uid, context=None):
341         if context is None:
342             context = {}
343         if 'categ_id' in context and context['categ_id']:
344             return context['categ_id']
345         md = self.pool.get('ir.model.data')
346         res = False
347         try:
348             res = md.get_object_reference(cr, uid, 'product', 'cat0')[1]
349         except ValueError:
350             res = False
351         return res
352
353     def onchange_uom(self, cursor, user, ids, uom_id,uom_po_id):
354         if uom_id:
355             return {'value': {'uom_po_id': uom_id}}
356         return {}
357
358     def write(self, cr, uid, ids, vals, context=None):
359         if 'uom_po_id' in vals:
360             new_uom = self.pool.get('product.uom').browse(cr, uid, vals['uom_po_id'], context=context)
361             for product in self.browse(cr, uid, ids, context=context):
362                 old_uom = product.uom_po_id
363                 if old_uom.category_id.id != new_uom.category_id.id:
364                     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 desactivate this product from the 'Procurement & Locations' tab and create a new one.") % (new_uom.name, old_uom.category_id.name, old_uom.name,))
365         return super(product_template, self).write(cr, uid, ids, vals, context=context)
366
367     _defaults = {
368         'company_id': lambda s,cr,uid,c: s.pool.get('res.company')._company_default_get(cr, uid, 'product.template', context=c),
369         'list_price': lambda *a: 1,
370         'cost_method': lambda *a: 'standard',
371         'supply_method': lambda *a: 'buy',
372         'standard_price': lambda *a: 1,
373         'sale_ok': lambda *a: 1,
374         'sale_delay': lambda *a: 7,
375         'produce_delay': lambda *a: 1,
376         'purchase_ok': lambda *a: 1,
377         'procure_method': lambda *a: 'make_to_stock',
378         'uom_id': _get_uom_id,
379         'uom_po_id': _get_uom_id,
380         'uos_coeff' : lambda *a: 1.0,
381         'mes_type' : lambda *a: 'fixed',
382         'categ_id' : _default_category,
383         'type' : lambda *a: 'consu',
384     }
385
386     def _check_uom(self, cursor, user, ids, context=None):
387         for product in self.browse(cursor, user, ids, context=context):
388             if product.uom_id.category_id.id <> product.uom_po_id.category_id.id:
389                 return False
390         return True
391
392     def _check_uos(self, cursor, user, ids, context=None):
393         for product in self.browse(cursor, user, ids, context=context):
394             if product.uos_id \
395                     and product.uos_id.category_id.id \
396                     == product.uom_id.category_id.id:
397                 return False
398         return True
399
400     _constraints = [
401         (_check_uom, 'Error: The default Unit of Measure and the purchase Unit of Measure must be in the same category.', ['uom_id']),
402     ]
403
404     def name_get(self, cr, user, ids, context=None):
405         if context is None:
406             context = {}
407         if 'partner_id' in context:
408             pass
409         return super(product_template, self).name_get(cr, user, ids, context)
410
411 product_template()
412
413 class product_product(osv.osv):
414     def view_header_get(self, cr, uid, view_id, view_type, context=None):
415         if context is None:
416             context = {}
417         res = super(product_product, self).view_header_get(cr, uid, view_id, view_type, context)
418         if (context.get('categ_id', False)):
419             return _('Products: ')+self.pool.get('product.category').browse(cr, uid, context['categ_id'], context=context).name
420         return res
421
422     def _product_price(self, cr, uid, ids, name, arg, context=None):
423         res = {}
424         if context is None:
425             context = {}
426         quantity = context.get('quantity') or 1.0
427         pricelist = context.get('pricelist', False)
428         partner = context.get('partner', False)
429         if pricelist:
430             for id in ids:
431                 try:
432                     price = self.pool.get('product.pricelist').price_get(cr,uid,[pricelist], id, quantity, partner=partner, context=context)[pricelist]
433                 except:
434                     price = 0.0
435                 res[id] = price
436         for id in ids:
437             res.setdefault(id, 0.0)
438         return res
439
440     def _get_product_available_func(states, what):
441         def _product_available(self, cr, uid, ids, name, arg, context=None):
442             return {}.fromkeys(ids, 0.0)
443         return _product_available
444
445     _product_qty_available = _get_product_available_func(('done',), ('in', 'out'))
446     _product_virtual_available = _get_product_available_func(('confirmed','waiting','assigned','done'), ('in', 'out'))
447     _product_outgoing_qty = _get_product_available_func(('confirmed','waiting','assigned'), ('out',))
448     _product_incoming_qty = _get_product_available_func(('confirmed','waiting','assigned'), ('in',))
449
450     def _product_lst_price(self, cr, uid, ids, name, arg, context=None):
451         res = {}
452         product_uom_obj = self.pool.get('product.uom')
453         for id in ids:
454             res.setdefault(id, 0.0)
455         for product in self.browse(cr, uid, ids, context=context):
456             if 'uom' in context:
457                 uom = product.uos_id or product.uom_id
458                 res[product.id] = product_uom_obj._compute_price(cr, uid,
459                         uom.id, product.list_price, context['uom'])
460             else:
461                 res[product.id] = product.list_price
462             res[product.id] =  (res[product.id] or 0.0) * (product.price_margin or 1.0) + product.price_extra
463         return res
464
465     def _get_partner_code_name(self, cr, uid, ids, product, partner_id, context=None):
466         for supinfo in product.seller_ids:
467             if supinfo.name.id == partner_id:
468                 return {'code': supinfo.product_code or product.default_code, 'name': supinfo.product_name or product.name, 'variants': ''}
469         res = {'code': product.default_code, 'name': product.name, 'variants': product.variants}
470         return res
471
472     def _product_code(self, cr, uid, ids, name, arg, context=None):
473         res = {}
474         if context is None:
475             context = {}
476         for p in self.browse(cr, uid, ids, context=context):
477             res[p.id] = self._get_partner_code_name(cr, uid, [], p, context.get('partner_id', None), context=context)['code']
478         return res
479
480     def _product_partner_ref(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             data = self._get_partner_code_name(cr, uid, [], p, context.get('partner_id', None), context=context)
486             if not data['variants']:
487                 data['variants'] = p.variants
488             if not data['code']:
489                 data['code'] = p.code
490             if not data['name']:
491                 data['name'] = p.name
492             res[p.id] = (data['code'] and ('['+data['code']+'] ') or '') + \
493                     (data['name'] or '') + (data['variants'] and (' - '+data['variants']) or '')
494         return res
495
496     def _get_image(self, cr, uid, ids, name, args, context=None):
497         result = dict.fromkeys(ids, False)
498         for obj in self.browse(cr, uid, ids, context=context):
499             resized_image_dict = tools.get_resized_images(obj.image)
500             result[obj.id] = {
501                 'image_medium': resized_image_dict['image_medium'],
502                 'image_small': resized_image_dict['image_small'],
503                 }
504         return result
505     
506     def _set_image(self, cr, uid, id, name, value, args, context=None):
507         return self.write(cr, uid, [id], {'image': tools.resize_image_big(value)}, context=context)
508     
509     def onchange_image(self, cr, uid, ids, value, context=None):
510         return {'value': tools.get_resized_images(value)}
511
512     _defaults = {
513         'active': lambda *a: 1,
514         'price_extra': lambda *a: 0.0,
515         'price_margin': lambda *a: 1.0,
516         'color': 0,
517     }
518
519     _name = "product.product"
520     _description = "Product"
521     _table = "product_product"
522     _inherits = {'product.template': 'product_tmpl_id'}
523     _inherit = ['mail.thread']
524     _order = 'default_code,name_template'
525     _columns = {
526         'qty_available': fields.function(_product_qty_available, type='float', string='Quantity On Hand'),
527         'virtual_available': fields.function(_product_virtual_available, type='float', string='Quantity Available'),
528         'incoming_qty': fields.function(_product_incoming_qty, type='float', string='Incoming'),
529         'outgoing_qty': fields.function(_product_outgoing_qty, type='float', string='Outgoing'),
530         'price': fields.function(_product_price, type='float', string='Pricelist', digits_compute=dp.get_precision('Sale Price')),
531         'lst_price' : fields.function(_product_lst_price, type='float', string='Public Price', digits_compute=dp.get_precision('Sale Price')),
532         'code': fields.function(_product_code, type='char', string='Reference'),
533         'partner_ref' : fields.function(_product_partner_ref, type='char', string='Customer ref'),
534         'default_code' : fields.char('Reference', size=64, select=True),
535         'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the product without removing it."),
536         'variants': fields.char('Variants', size=64),
537         'product_tmpl_id': fields.many2one('product.template', 'Product Template', required=True, ondelete="cascade"),
538         'ean13': fields.char('EAN13', size=13, help="The numbers encoded in EAN-13 bar codes are product identification numbers."),
539         '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."),
540         'price_extra': fields.float('Variant Price Extra', digits_compute=dp.get_precision('Sale Price')),
541         'price_margin': fields.float('Variant Price Margin', digits_compute=dp.get_precision('Sale Price')),
542         'pricelist_id': fields.dummy(string='Pricelist', relation='product.pricelist', type='many2one'),
543         'name_template': fields.related('product_tmpl_id', 'name', string="Name", type='char', size=128, store=True, select=True),
544         'color': fields.integer('Color Index'),
545         'image': fields.binary("Image",
546             help="This field holds the image used for the product. "\
547                  "The image is base64 encoded, and PIL-supported. "\
548                  "It is limited to a 12024x1024 px image."),
549         'image_medium': fields.function(_get_image, fnct_inv=_set_image,
550             string="Medium-sized image", type="binary", multi="_get_image",
551             store = {
552                 'product.product': (lambda self, cr, uid, ids, c={}: ids, ['image'], 10),
553             },
554             help="Medium-sized image of the product. It is automatically "\
555                  "resized as a 180x180px image, with aspect ratio kept. "\
556                  "Use this field in form views or some kanban views."),
557         'image_small': fields.function(_get_image, fnct_inv=_set_image,
558             string="Smal-sized image", type="binary", multi="_get_image",
559             store = {
560                 'product.product': (lambda self, cr, uid, ids, c={}: ids, ['image'], 10),
561             },
562             help="Small-sized image of the product. It is automatically "\
563                  "resized as a 50x50px image, with aspect ratio keps. "\
564                  "Use this field anywhere a small image is required."),
565     }
566
567     def create(self, cr, uid, vals, context=None):
568         obj_id = super(product_product, self).create(cr, uid, vals, context=context)
569         self.create_send_note(cr, uid, [obj_id], context=context)
570         return obj_id
571
572     def create_send_note(self, cr, uid, ids, context=None):
573         return self.message_append_note(cr, uid, ids, body=_("Product has been <b>created</b>."), context=context)
574
575     def unlink(self, cr, uid, ids, context=None):
576         unlink_ids = []
577         unlink_product_tmpl_ids = []
578         for product in self.browse(cr, uid, ids, context=context):
579             tmpl_id = product.product_tmpl_id.id
580             # Check if the product is last product of this template
581             other_product_ids = self.search(cr, uid, [('product_tmpl_id', '=', tmpl_id), ('id', '!=', product.id)], context=context)
582             if not other_product_ids:
583                  unlink_product_tmpl_ids.append(tmpl_id)
584             unlink_ids.append(product.id)
585         self.pool.get('product.template').unlink(cr, uid, unlink_product_tmpl_ids, context=context)
586         return super(product_product, self).unlink(cr, uid, unlink_ids, context=context)
587
588     def onchange_uom(self, cursor, user, ids, uom_id,uom_po_id):
589         if uom_id and uom_po_id:
590             uom_obj=self.pool.get('product.uom')
591             uom=uom_obj.browse(cursor,user,[uom_id])[0]
592             uom_po=uom_obj.browse(cursor,user,[uom_po_id])[0]
593             if uom.category_id.id != uom_po.category_id.id:
594                 return {'value': {'uom_po_id': uom_id}}
595         return False
596
597     def _check_ean_key(self, cr, uid, ids, context=None):
598         for product in self.browse(cr, uid, ids, context=context):
599             res = check_ean(product.ean13)
600         return res
601
602     _constraints = [(_check_ean_key, 'Error: Invalid ean code', ['ean13'])]
603
604     def on_order(self, cr, uid, ids, orderline, quantity):
605         pass
606
607     def name_get(self, cr, user, ids, context=None):
608         if context is None:
609             context = {}
610         if not len(ids):
611             return []
612         def _name_get(d):
613             name = d.get('name','')
614             code = d.get('default_code',False)
615             if code:
616                 name = '[%s] %s' % (code,name)
617             if d.get('variants'):
618                 name = name + ' - %s' % (d['variants'],)
619             return (d['id'], name)
620
621         partner_id = context.get('partner_id', False)
622
623         result = []
624         for product in self.browse(cr, user, ids, context=context):
625             sellers = filter(lambda x: x.name.id == partner_id, product.seller_ids)
626             if sellers:
627                 for s in sellers:
628                     mydict = {
629                               'id': product.id,
630                               'name': s.product_name or product.name,
631                               'default_code': s.product_code or product.default_code,
632                               'variants': product.variants
633                               }
634                     result.append(_name_get(mydict))
635             else:
636                 mydict = {
637                           'id': product.id,
638                           'name': product.name,
639                           'default_code': product.default_code,
640                           'variants': product.variants
641                           }
642                 result.append(_name_get(mydict))
643         return result
644
645     def name_search(self, cr, user, name='', args=None, operator='ilike', context=None, limit=100):
646         if not args:
647             args = []
648         if name:
649             ids = self.search(cr, user, [('default_code','=',name)]+ args, limit=limit, context=context)
650             if not ids:
651                 ids = self.search(cr, user, [('ean13','=',name)]+ args, limit=limit, context=context)
652             if not ids:
653                 # Do not merge the 2 next lines into one single search, SQL search performance would be abysmal
654                 # on a database with thousands of matching products, due to the huge merge+unique needed for the
655                 # OR operator (and given the fact that the 'name' lookup results come from the ir.translation table
656                 # Performing a quick memory merge of ids in Python will give much better performance
657                 ids = set()
658                 ids.update(self.search(cr, user, args + [('default_code',operator,name)], limit=limit, context=context))
659                 if len(ids) < limit:
660                     # we may underrun the limit because of dupes in the results, that's fine
661                     ids.update(self.search(cr, user, args + [('name',operator,name)], limit=(limit-len(ids)), context=context))
662                 ids = list(ids)
663             if not ids:
664                 ptrn = re.compile('(\[(.*?)\])')
665                 res = ptrn.search(name)
666                 if res:
667                     ids = self.search(cr, user, [('default_code','=', res.group(2))] + args, limit=limit, context=context)
668         else:
669             ids = self.search(cr, user, args, limit=limit, context=context)
670         result = self.name_get(cr, user, ids, context=context)
671         return result
672
673     #
674     # Could be overrided for variants matrices prices
675     #
676     def price_get(self, cr, uid, ids, ptype='list_price', context=None):
677         if context is None:
678             context = {}
679
680         if 'currency_id' in context:
681             pricetype_obj = self.pool.get('product.price.type')
682             price_type_id = pricetype_obj.search(cr, uid, [('field','=',ptype)])[0]
683             price_type_currency_id = pricetype_obj.browse(cr,uid,price_type_id).currency_id.id
684
685         res = {}
686         product_uom_obj = self.pool.get('product.uom')
687         for product in self.browse(cr, uid, ids, context=context):
688             res[product.id] = product[ptype] or 0.0
689             if ptype == 'list_price':
690                 res[product.id] = (res[product.id] * (product.price_margin or 1.0)) + \
691                         product.price_extra
692             if 'uom' in context:
693                 uom = product.uom_id or product.uos_id
694                 res[product.id] = product_uom_obj._compute_price(cr, uid,
695                         uom.id, res[product.id], context['uom'])
696             # Convert from price_type currency to asked one
697             if 'currency_id' in context:
698                 # Take the price_type currency from the product field
699                 # This is right cause a field cannot be in more than one currency
700                 res[product.id] = self.pool.get('res.currency').compute(cr, uid, price_type_currency_id,
701                     context['currency_id'], res[product.id],context=context)
702
703         return res
704
705     def copy(self, cr, uid, id, default=None, context=None):
706         if context is None:
707             context={}
708
709         if not default:
710             default = {}
711
712         # Craft our own `<name> (copy)` in en_US (self.copy_translation()
713         # will do the other languages).
714         context_wo_lang = context.copy()
715         context_wo_lang.pop('lang', None)
716         product = self.read(cr, uid, id, ['name'], context=context_wo_lang)
717         default = default.copy()
718         default['name'] = product['name'] + ' (copy)'
719
720         if context.get('variant',False):
721             fields = ['product_tmpl_id', 'active', 'variants', 'default_code',
722                     'price_margin', 'price_extra']
723             data = self.read(cr, uid, id, fields=fields, context=context)
724             for f in fields:
725                 if f in default:
726                     data[f] = default[f]
727             data['product_tmpl_id'] = data.get('product_tmpl_id', False) \
728                     and data['product_tmpl_id'][0]
729             del data['id']
730             return self.create(cr, uid, data)
731         else:
732             return super(product_product, self).copy(cr, uid, id, default=default,
733                     context=context)
734 product_product()
735
736 class product_packaging(osv.osv):
737     _name = "product.packaging"
738     _description = "Packaging"
739     _rec_name = 'ean'
740     _order = 'sequence'
741     _columns = {
742         'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of packaging."),
743         'name' : fields.text('Description', size=64),
744         'qty' : fields.float('Quantity by Package',
745             help="The total number of products you can put by pallet or box."),
746         'ul' : fields.many2one('product.ul', 'Type of Package', required=True),
747         'ul_qty' : fields.integer('Package by layer', help='The number of packages by layer'),
748         'rows' : fields.integer('Number of Layers', required=True,
749             help='The number of layers on a pallet or box'),
750         'product_id' : fields.many2one('product.product', 'Product', select=1, ondelete='cascade', required=True),
751         'ean' : fields.char('EAN', size=14,
752             help="The EAN code of the package unit."),
753         'code' : fields.char('Code', size=14,
754             help="The code of the transport unit."),
755         'weight': fields.float('Total Package Weight',
756             help='The weight of a full package, pallet or box.'),
757         'weight_ul': fields.float('Empty Package Weight',
758             help='The weight of the empty UL'),
759         'height': fields.float('Height', help='The height of the package'),
760         'width': fields.float('Width', help='The width of the package'),
761         'length': fields.float('Length', help='The length of the package'),
762     }
763
764
765     def _check_ean_key(self, cr, uid, ids, context=None):
766         for pack in self.browse(cr, uid, ids, context=context):
767             res = check_ean(pack.ean)
768         return res
769
770     _constraints = [(_check_ean_key, 'Error: Invalid ean code', ['ean'])]
771
772     def name_get(self, cr, uid, ids, context=None):
773         if not len(ids):
774             return []
775         res = []
776         for pckg in self.browse(cr, uid, ids, context=context):
777             p_name = pckg.ean and '[' + pckg.ean + '] ' or ''
778             p_name += pckg.ul.name
779             res.append((pckg.id,p_name))
780         return res
781
782     def _get_1st_ul(self, cr, uid, context=None):
783         cr.execute('select id from product_ul order by id asc limit 1')
784         res = cr.fetchone()
785         return (res and res[0]) or False
786
787     _defaults = {
788         'rows' : lambda *a : 3,
789         'sequence' : lambda *a : 1,
790         'ul' : _get_1st_ul,
791     }
792
793     def checksum(ean):
794         salt = '31' * 6 + '3'
795         sum = 0
796         for ean_part, salt_part in zip(ean, salt):
797             sum += int(ean_part) * int(salt_part)
798         return (10 - (sum % 10)) % 10
799     checksum = staticmethod(checksum)
800
801 product_packaging()
802
803
804 class product_supplierinfo(osv.osv):
805     _name = "product.supplierinfo"
806     _description = "Information about a product supplier"
807     def _calc_qty(self, cr, uid, ids, fields, arg, context=None):
808         result = {}
809         product_uom_pool = self.pool.get('product.uom')
810         for supplier_info in self.browse(cr, uid, ids, context=context):
811             for field in fields:
812                 result[supplier_info.id] = {field:False}
813             qty = supplier_info.min_qty
814             result[supplier_info.id]['qty'] = qty
815         return result
816
817     _columns = {
818         'name' : fields.many2one('res.partner', 'Supplier', required=True,domain = [('supplier','=',True)], ondelete='cascade', help="Supplier of this product"),
819         '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."),
820         '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."),
821         'sequence' : fields.integer('Sequence', help="Assigns the priority to the list of product supplier."),
822         '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."),
823         '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."),
824         '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."),
825         'product_id' : fields.many2one('product.template', 'Product', required=True, ondelete='cascade', select=True),
826         '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."),
827         'pricelist_ids': fields.one2many('pricelist.partnerinfo', 'suppinfo_id', 'Supplier Pricelist'),
828         'company_id':fields.many2one('res.company','Company',select=1),
829     }
830     _defaults = {
831         'qty': lambda *a: 0.0,
832         'sequence': lambda *a: 1,
833         'delay': lambda *a: 1,
834         'company_id': lambda self,cr,uid,c: self.pool.get('res.company')._company_default_get(cr, uid, 'product.supplierinfo', context=c),
835     }
836     def price_get(self, cr, uid, supplier_ids, product_id, product_qty=1, context=None):
837         """
838         Calculate price from supplier pricelist.
839         @param supplier_ids: Ids of res.partner object.
840         @param product_id: Id of product.
841         @param product_qty: specify quantity to purchase.
842         """
843         if type(supplier_ids) in (int,long,):
844             supplier_ids = [supplier_ids]
845         res = {}
846         product_pool = self.pool.get('product.product')
847         partner_pool = self.pool.get('res.partner')
848         pricelist_pool = self.pool.get('product.pricelist')
849         currency_pool = self.pool.get('res.currency')
850         currency_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id.currency_id.id
851         for supplier in partner_pool.browse(cr, uid, supplier_ids, context=context):
852             # Compute price from standard price of product
853             price = product_pool.price_get(cr, uid, [product_id], 'standard_price', context=context)[product_id]
854
855             # Compute price from Purchase pricelist of supplier
856             pricelist_id = supplier.property_product_pricelist_purchase.id
857             if pricelist_id:
858                 price = pricelist_pool.price_get(cr, uid, [pricelist_id], product_id, product_qty, context=context).setdefault(pricelist_id, 0)
859                 price = currency_pool.compute(cr, uid, pricelist_pool.browse(cr, uid, pricelist_id).currency_id.id, currency_id, price)
860
861             # Compute price from supplier pricelist which are in Supplier Information
862             supplier_info_ids = self.search(cr, uid, [('name','=',supplier.id),('product_id','=',product_id)])
863             if supplier_info_ids:
864                 cr.execute('SELECT * ' \
865                     'FROM pricelist_partnerinfo ' \
866                     'WHERE suppinfo_id IN %s' \
867                     'AND min_quantity <= %s ' \
868                     'ORDER BY min_quantity DESC LIMIT 1', (tuple(supplier_info_ids),product_qty,))
869                 res2 = cr.dictfetchone()
870                 if res2:
871                     price = res2['price']
872             res[supplier.id] = price
873         return res
874     _order = 'sequence'
875 product_supplierinfo()
876
877
878 class pricelist_partnerinfo(osv.osv):
879     _name = 'pricelist.partnerinfo'
880     _columns = {
881         'name': fields.char('Description', size=64),
882         'suppinfo_id': fields.many2one('product.supplierinfo', 'Partner Information', required=True, ondelete='cascade'),
883         '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."),
884         'price': fields.float('Unit Price', required=True, digits_compute=dp.get_precision('Purchase 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"),
885     }
886     _order = 'min_quantity asc'
887 pricelist_partnerinfo()
888 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: