[MERGE] master
[odoo/odoo.git] / addons / stock / 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 openerp.osv import fields, osv
23 from openerp.tools.translate import _
24 from openerp.tools.safe_eval import safe_eval as eval
25 import openerp.addons.decimal_precision as dp
26
27 class product_product(osv.osv):
28     _inherit = "product.product"
29         
30     def _stock_move_count(self, cr, uid, ids, field_name, arg, context=None):
31         res = dict([(id, {'reception_count': 0, 'delivery_count': 0}) for id in ids])
32         move_pool=self.pool.get('stock.move')
33         moves = move_pool.read_group(cr, uid, [
34             ('product_id', 'in', ids),
35             ('location_id.usage', '!=', 'internal'),
36             ('location_dest_id.usage', '=', 'internal'),
37             ('state','in',('confirmed','assigned','pending'))
38         ], ['product_id'], ['product_id'])
39         for move in moves:
40             product_id = move['product_id'][0]
41             res[product_id]['reception_count'] = move['product_id_count']
42         moves = move_pool.read_group(cr, uid, [
43             ('product_id', 'in', ids),
44             ('location_id.usage', '=', 'internal'),
45             ('location_dest_id.usage', '!=', 'internal'),
46             ('state','in',('confirmed','assigned','pending'))
47         ], ['product_id'], ['product_id'])
48         for move in moves:
49             product_id = move['product_id'][0]
50             res[product_id]['delivery_count'] = move['product_id_count']
51         return res
52
53     def view_header_get(self, cr, user, view_id, view_type, context=None):
54         if context is None:
55             context = {}
56         res = super(product_product, self).view_header_get(cr, user, view_id, view_type, context)
57         if res: return res
58         if (context.get('active_id', False)) and (context.get('active_model') == 'stock.location'):
59             return _('Products: ')+self.pool.get('stock.location').browse(cr, user, context['active_id'], context).name
60         return res
61
62     def _get_domain_locations(self, cr, uid, ids, context=None):
63         '''
64         Parses the context and returns a list of location_ids based on it.
65         It will return all stock locations when no parameters are given
66         Possible parameters are shop, warehouse, location, force_company, compute_child
67         '''
68         context = context or {}
69
70         location_obj = self.pool.get('stock.location')
71         warehouse_obj = self.pool.get('stock.warehouse')
72
73         location_ids = []
74         if context.get('location', False):
75             if type(context['location']) == type(1):
76                 location_ids = [context['location']]
77             elif type(context['location']) in (type(''), type(u'')):
78                 domain = [('complete_name','ilike',context['location'])]
79                 if context.get('force_company', False):
80                     domain += [('company_id', '=', context['force_company'])]
81                 location_ids = location_obj.search(cr, uid, domain, context=context)
82             else:
83                 location_ids = context['location']
84         else:
85             if context.get('warehouse', False):
86                 wids = [context['warehouse']]
87             else:
88                 wids = warehouse_obj.search(cr, uid, [], context=context)
89
90             for w in warehouse_obj.browse(cr, uid, wids, context=context):
91                 location_ids.append(w.view_location_id.id)
92
93         operator = context.get('compute_child', True) and 'child_of' or 'in'
94         domain = context.get('force_company', False) and ['&', ('company_id', '=', context['force_company'])] or []
95         return (
96             domain + [('location_id', operator, location_ids)],
97             domain + ['&', ('location_dest_id', operator, location_ids), '!', ('location_id', operator, location_ids)],
98             domain + ['&', ('location_id', operator, location_ids), '!', ('location_dest_id', operator, location_ids)]
99         )
100
101     def _get_domain_dates(self, cr, uid, ids, context):
102         from_date = context.get('from_date', False)
103         to_date = context.get('to_date', False)
104         domain = []
105         if from_date:
106             domain.append(('date', '>=', from_date))
107         if to_date:
108             domain.append(('date', '<=', to_date))
109         return domain
110
111     def _product_available(self, cr, uid, ids, field_names=None, arg=False, context=None):
112         context = context or {}
113         field_names = field_names or []
114
115         domain_products = [('product_id', 'in', ids)]
116         domain_quant, domain_move_in, domain_move_out = self._get_domain_locations(cr, uid, ids, context=context)
117         domain_move_in += self._get_domain_dates(cr, uid, ids, context=context) + [('state', 'not in', ('done', 'cancel'))] + domain_products
118         domain_move_out += self._get_domain_dates(cr, uid, ids, context=context) + [('state', 'not in', ('done', 'cancel'))] + domain_products
119         domain_quant += domain_products
120         if context.get('lot_id') or context.get('owner_id') or context.get('package_id'):
121             if context.get('lot_id'):
122                 domain_quant.append(('lot_id', '=', context['lot_id']))
123             if context.get('owner_id'):
124                 domain_quant.append(('owner_id', '=', context['owner_id']))
125             if context.get('package_id'):
126                 domain_quant.append(('package_id', '=', context['package_id']))
127             moves_in = []
128             moves_out = []
129         else:
130             moves_in = self.pool.get('stock.move').read_group(cr, uid, domain_move_in, ['product_id', 'product_qty'], ['product_id'], context=context)
131             moves_out = self.pool.get('stock.move').read_group(cr, uid, domain_move_out, ['product_id', 'product_qty'], ['product_id'], context=context)
132
133         quants = self.pool.get('stock.quant').read_group(cr, uid, domain_quant, ['product_id', 'qty'], ['product_id'], context=context)
134         quants = dict(map(lambda x: (x['product_id'][0], x['qty']), quants))
135
136         moves_in = dict(map(lambda x: (x['product_id'][0], x['product_qty']), moves_in))
137         moves_out = dict(map(lambda x: (x['product_id'][0], x['product_qty']), moves_out))
138         res = {}
139         for id in ids:
140             res[id] = {
141                 'qty_available': quants.get(id, 0.0),
142                 'incoming_qty': moves_in.get(id, 0.0),
143                 'outgoing_qty': moves_out.get(id, 0.0),
144                 'virtual_available': quants.get(id, 0.0) + moves_in.get(id, 0.0) - moves_out.get(id, 0.0),
145             }
146
147         return res
148
149     def _search_product_quantity(self, cr, uid, obj, name, domain, context):
150         res = []
151         for field, operator, value in domain:
152             #to prevent sql injections
153             assert field in ('qty_available', 'virtual_available', 'incoming_qty', 'outgoing_qty'), 'Invalid domain left operand'
154             assert operator in ('<', '>', '=', '<=', '>='), 'Invalid domain operator'
155             assert isinstance(value, (float, int)), 'Invalid domain right operand'
156
157             if operator == '=':
158                 operator = '=='
159
160             product_ids = self.search(cr, uid, [], context=context)
161             ids = []
162             if product_ids:
163                 #TODO: use a query instead of this browse record which is probably making the too much requests, but don't forget
164                 #the context that can be set with a location, an owner...
165                 for element in self.browse(cr, uid, product_ids, context=context):
166                     if eval(str(element[field]) + operator + str(value)):
167                         ids.append(element.id)
168             res.append(('id', 'in', ids))
169         return res
170
171     def _product_available_text(self, cr, uid, ids, field_names=None, arg=False, context=None):
172         res = {}
173         for product in self.browse(cr, uid, ids, context=context):
174             res[product.id] = str(product.qty_available) +  _(" In Stock")
175         return res
176
177     _columns = {
178         'reception_count': fields.function(_stock_move_count, string="Reception", type='integer', multi='pickings'),
179         'delivery_count': fields.function(_stock_move_count, string="Delivery", type='integer', multi='pickings'),
180         'qty_in_stock': fields.function(_product_available_text, type='char'),
181         'qty_available': fields.function(_product_available, multi='qty_available',
182             type='float', digits_compute=dp.get_precision('Product Unit of Measure'),
183             string='Quantity On Hand',
184             fnct_search=_search_product_quantity,
185             help="Current quantity of products.\n"
186                  "In a context with a single Stock Location, this includes "
187                  "goods stored at this Location, or any of its children.\n"
188                  "In a context with a single Warehouse, this includes "
189                  "goods stored in the Stock Location of this Warehouse, or any "
190                  "of its children.\n"
191                  "stored in the Stock Location of the Warehouse of this Shop, "
192                  "or any of its children.\n"
193                  "Otherwise, this includes goods stored in any Stock Location "
194                  "with 'internal' type."),
195         'virtual_available': fields.function(_product_available, multi='qty_available',
196             type='float', digits_compute=dp.get_precision('Product Unit of Measure'),
197             string='Forecast Quantity',
198             fnct_search=_search_product_quantity,
199             help="Forecast quantity (computed as Quantity On Hand "
200                  "- Outgoing + Incoming)\n"
201                  "In a context with a single Stock Location, this includes "
202                  "goods stored in this location, or any of its children.\n"
203                  "In a context with a single Warehouse, this includes "
204                  "goods stored in the Stock Location of this Warehouse, or any "
205                  "of its children.\n"
206                  "Otherwise, this includes goods stored in any Stock Location "
207                  "with 'internal' type."),
208         'incoming_qty': fields.function(_product_available, multi='qty_available',
209             type='float', digits_compute=dp.get_precision('Product Unit of Measure'),
210             string='Incoming',
211             fnct_search=_search_product_quantity,
212             help="Quantity of products that are planned to arrive.\n"
213                  "In a context with a single Stock Location, this includes "
214                  "goods arriving to this Location, or any of its children.\n"
215                  "In a context with a single Warehouse, this includes "
216                  "goods arriving to the Stock Location of this Warehouse, or "
217                  "any of its children.\n"
218                  "Otherwise, this includes goods arriving to any Stock "
219                  "Location with 'internal' type."),
220         'outgoing_qty': fields.function(_product_available, multi='qty_available',
221             type='float', digits_compute=dp.get_precision('Product Unit of Measure'),
222             string='Outgoing',
223             fnct_search=_search_product_quantity,
224             help="Quantity of products that are planned to leave.\n"
225                  "In a context with a single Stock Location, this includes "
226                  "goods leaving this Location, or any of its children.\n"
227                  "In a context with a single Warehouse, this includes "
228                  "goods leaving the Stock Location of this Warehouse, or "
229                  "any of its children.\n"
230                  "Otherwise, this includes goods leaving any Stock "
231                  "Location with 'internal' type."),
232         'location_id': fields.dummy(string='Location', relation='stock.location', type='many2one'),
233         'warehouse_id': fields.dummy(string='Warehouse', relation='stock.warehouse', type='many2one'),
234         'orderpoint_ids': fields.one2many('stock.warehouse.orderpoint', 'product_id', 'Minimum Stock Rules'),
235     }
236
237     def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
238         res = super(product_product,self).fields_view_get(cr, uid, view_id, view_type, context, toolbar=toolbar, submenu=submenu)
239         if context is None:
240             context = {}
241         if ('location' in context) and context['location']:
242             location_info = self.pool.get('stock.location').browse(cr, uid, context['location'])
243             fields=res.get('fields',{})
244             if fields:
245                 if location_info.usage == 'supplier':
246                     if fields.get('virtual_available'):
247                         res['fields']['virtual_available']['string'] = _('Future Receptions')
248                     if fields.get('qty_available'):
249                         res['fields']['qty_available']['string'] = _('Received Qty')
250
251                 if location_info.usage == 'internal':
252                     if fields.get('virtual_available'):
253                         res['fields']['virtual_available']['string'] = _('Future Stock')
254
255                 if location_info.usage == 'customer':
256                     if fields.get('virtual_available'):
257                         res['fields']['virtual_available']['string'] = _('Future Deliveries')
258                     if fields.get('qty_available'):
259                         res['fields']['qty_available']['string'] = _('Delivered Qty')
260
261                 if location_info.usage == 'inventory':
262                     if fields.get('virtual_available'):
263                         res['fields']['virtual_available']['string'] = _('Future P&L')
264                     if fields.get('qty_available'):
265                         res['fields']['qty_available']['string'] = _('P&L Qty')
266
267                 if location_info.usage == 'procurement':
268                     if fields.get('virtual_available'):
269                         res['fields']['virtual_available']['string'] = _('Future Qty')
270                     if fields.get('qty_available'):
271                         res['fields']['qty_available']['string'] = _('Unplanned Qty')
272
273                 if location_info.usage == 'production':
274                     if fields.get('virtual_available'):
275                         res['fields']['virtual_available']['string'] = _('Future Productions')
276                     if fields.get('qty_available'):
277                         res['fields']['qty_available']['string'] = _('Produced Qty')
278         return res
279
280 class product_template(osv.osv):
281     _name = 'product.template'
282     _inherit = 'product.template'
283     
284     def _product_available(self, cr, uid, ids, name, arg, context=None):
285         res = dict.fromkeys(ids, 0)
286         for product in self.browse(cr, uid, ids, context=context):
287             res[product.id] = {
288                 # "reception_count": sum([p.reception_count for p in product.product_variant_ids]),
289                 # "delivery_count": sum([p.delivery_count for p in product.product_variant_ids]),
290                 "qty_available": sum([p.qty_available for p in product.product_variant_ids]),
291                 "virtual_available": sum([p.virtual_available for p in product.product_variant_ids]),
292                 "incoming_qty": sum([p.incoming_qty for p in product.product_variant_ids]),
293                 "outgoing_qty": sum([p.outgoing_qty for p in product.product_variant_ids]),
294             }
295         return res
296
297     def _search_product_quantity(self, cr, uid, obj, name, domain, context):
298         prod = self.pool.get("product.product")
299         res = []
300         for field, operator, value in domain:
301             #to prevent sql injections
302             assert field in ('qty_available', 'virtual_available', 'incoming_qty', 'outgoing_qty'), 'Invalid domain left operand'
303             assert operator in ('<', '>', '=', '<=', '>='), 'Invalid domain operator'
304             assert isinstance(value, (float, int)), 'Invalid domain right operand'
305
306             if operator == '=':
307                 operator = '=='
308
309             product_ids = prod.search(cr, uid, [], context=context)
310             ids = []
311             if product_ids:
312                 #TODO: use a query instead of this browse record which is probably making the too much requests, but don't forget
313                 #the context that can be set with a location, an owner...
314                 for element in prod.browse(cr, uid, product_ids, context=context):
315                     if eval(str(element[field]) + operator + str(value)):
316                         ids.append(element.id)
317             res.append(('product_variant_ids', 'in', ids))
318         return res
319
320     _columns = {
321         'valuation':fields.selection([('manual_periodic', 'Periodical (manual)'),
322             ('real_time','Real Time (automated)'),], 'Inventory Valuation',
323             help="If real-time valuation is enabled for a product, the system will automatically write journal entries corresponding to stock moves." \
324                  "The inventory variation account set on the product category will represent the current inventory value, and the stock input and stock output account will hold the counterpart moves for incoming and outgoing products."
325             , required=True),
326         'type': fields.selection([('product', 'Stockable Product'), ('consu', 'Consumable'), ('service', 'Service')], 'Product Type', required=True, help="Consumable: Will not imply stock management for this product. \nStockable product: Will imply stock management for this product."),
327         'property_stock_procurement': fields.property(
328             type='many2one',
329             relation='stock.location',
330             string="Procurement Location",
331             domain=[('usage','like','procurement')],
332             help="This stock location will be used, instead of the default one, as the source location for stock moves generated by procurements."),
333         'property_stock_production': fields.property(
334             type='many2one',
335             relation='stock.location',
336             string="Production Location",
337             domain=[('usage','like','production')],
338             help="This stock location will be used, instead of the default one, as the source location for stock moves generated by manufacturing orders."),
339         'property_stock_inventory': fields.property(
340             type='many2one',
341             relation='stock.location',
342             string="Inventory Location",
343             domain=[('usage','like','inventory')],
344             help="This stock location will be used, instead of the default one, as the source location for stock moves generated when you do an inventory."),
345         'sale_delay': fields.float('Customer Lead Time', help="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."),
346         'loc_rack': fields.char('Rack', size=16),
347         'loc_row': fields.char('Row', size=16),
348         'loc_case': fields.char('Case', size=16),
349         'track_incoming': fields.boolean('Track Incoming Lots', help="Forces to specify a Serial Number for all moves containing this product and coming from a Supplier Location"),
350         'track_outgoing': fields.boolean('Track Outgoing Lots', help="Forces to specify a Serial Number for all moves containing this product and going to a Customer Location"),
351         'track_all': fields.boolean('Full Lots Traceability', help="Forces to specify a Serial Number on each and every operation related to this product"),
352         
353         # sum of product variant qty
354         # 'reception_count': fields.function(_product_available, multi='qty_available',
355         #     fnct_search=_search_product_quantity, type='float', string='Quantity On Hand'),
356         # 'delivery_count': fields.function(_product_available, multi='qty_available',
357         #     fnct_search=_search_product_quantity, type='float', string='Quantity On Hand'),
358         'qty_available': fields.function(_product_available, multi='qty_available',
359             fnct_search=_search_product_quantity, type='float', string='Quantity On Hand'),
360         'virtual_available': fields.function(_product_available, multi='qty_available',
361             fnct_search=_search_product_quantity, type='float', string='Quantity Available'),
362         'incoming_qty': fields.function(_product_available, multi='qty_available',
363             fnct_search=_search_product_quantity, type='float', string='Incoming'),
364         'outgoing_qty': fields.function(_product_available, multi='qty_available',
365             fnct_search=_search_product_quantity, type='float', string='Outgoing'),
366         
367         'route_ids': fields.many2many('stock.location.route', 'stock_route_product', 'product_id', 'route_id', 'Routes', domain="[('product_selectable', '=', True)]",
368                                     help="Depending on the modules installed, this will allow you to define the route of the product: whether it will be bought, manufactured, MTO/MTS,..."),
369     }
370
371     _defaults = {
372         'sale_delay': 7,
373         'valuation': 'manual_periodic',
374     }
375
376     def action_view_routes(self, cr, uid, ids, context=None):
377         route_obj = self.pool.get("stock.location.route")
378         act_obj = self.pool.get('ir.actions.act_window')
379         mod_obj = self.pool.get('ir.model.data')
380         product_route_ids = set()
381         for product in self.browse(cr, uid, ids, context=context):
382             product_route_ids |= set([r.id for r in product.route_ids])
383             product_route_ids |= set([r.id for r in product.categ_id.total_route_ids])
384         route_ids = route_obj.search(cr, uid, ['|', ('id', 'in', list(product_route_ids)), ('warehouse_selectable', '=', True)], context=context)
385         result = mod_obj.get_object_reference(cr, uid, 'stock', 'action_routes_form')
386         id = result and result[1] or False
387         result = act_obj.read(cr, uid, [id], context=context)[0]
388         result['domain'] = "[('id','in',[" + ','.join(map(str, route_ids)) + "])]"
389         return result
390
391 class product_removal_strategy(osv.osv):
392     _name = 'product.removal'
393     _description = 'Removal Strategy'
394
395     _columns = {
396         'name': fields.char('Name', required=True),
397         'method': fields.char("Method", required=True, help="FIFO, LIFO..."),
398     }
399
400
401 class product_putaway_strategy(osv.osv):
402     _name = 'product.putaway'
403     _description = 'Put Away Strategy'
404
405     def _get_putaway_options(self, cr, uid, context=None):
406         return [('fixed', 'Fixed Location')]
407
408     _columns = {
409         'name': fields.char('Name', required=True),
410         'method': fields.selection(_get_putaway_options, "Method", required=True),
411         'fixed_location_ids': fields.one2many('stock.fixed.putaway.strat', 'putaway_id', 'Fixed Locations Per Product Category', help="When the method is fixed, this location will be used to store the products"),
412     }
413
414     _defaults = {
415         'method': 'fixed',
416     }
417
418     def putaway_apply(self, cr, uid, putaway_strat, product, context=None):
419         if putaway_strat.method == 'fixed':
420             for strat in putaway_strat.fixed_location_ids:
421                 categ = product.categ_id
422                 while categ:
423                     if strat.category_id.id == categ.id:
424                         return strat.fixed_location_id.id
425                     categ = categ.parent_id
426
427
428 class fixed_putaway_strat(osv.osv):
429     _name = 'stock.fixed.putaway.strat'
430     _order = 'sequence'
431     _columns = {
432         'putaway_id': fields.many2one('product.putaway', 'Put Away Method', required=True),
433         'category_id': fields.many2one('product.category', 'Product Category', required=True),
434         'fixed_location_id': fields.many2one('stock.location', 'Location', required=True),
435         'sequence': fields.integer('Priority', help="Give to the more specialized category, a higher priority to have them in top of the list."),
436     }
437
438
439 class product_category(osv.osv):
440     _inherit = 'product.category'
441
442     def calculate_total_routes(self, cr, uid, ids, name, args, context=None):
443         res = {}
444         for categ in self.browse(cr, uid, ids, context=context):
445             categ2 = categ
446             routes = [x.id for x in categ.route_ids]
447             while categ2.parent_id:
448                 categ2 = categ2.parent_id
449                 routes += [x.id for x in categ2.route_ids]
450             res[categ.id] = routes
451         return res
452
453     _columns = {
454         'route_ids': fields.many2many('stock.location.route', 'stock_location_route_categ', 'categ_id', 'route_id', 'Routes', domain="[('product_categ_selectable', '=', True)]"),
455         'removal_strategy_id': fields.many2one('product.removal', 'Force Removal Strategy', help="Set a specific removal strategy that will be used regardless of the source location for this product category"),
456         'total_route_ids': fields.function(calculate_total_routes, relation='stock.location.route', type='many2many', string='Total routes', readonly=True),
457     }
458
459
460 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: