[FIX]stock: product Forecasted Quantity, incoming and outgoing help tooltip were...
[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 import openerp.addons.decimal_precision as dp
25
26 class product_product(osv.osv):
27     _inherit = "product.product"
28
29     def _stock_move_count(self, cr, uid, ids, field_name, arg, context=None):
30         res = dict([(id, {'reception_count': 0, 'delivery_count': 0}) for id in ids])
31         move_pool=self.pool.get('stock.move')
32         moves = move_pool.read_group(cr, uid, [
33             ('product_id', 'in', ids),
34             ('picking_id.type', '=', 'in'),
35             ('state','in',('confirmed','assigned','pending'))
36         ], ['product_id'], ['product_id'])
37         for move in moves:
38             product_id = move['product_id'][0]
39             res[product_id]['reception_count'] = move['product_id_count']
40         moves = move_pool.read_group(cr, uid, [
41             ('product_id', 'in', ids),
42             ('picking_id.type', '=', 'out'),
43             ('state','in',('confirmed','assigned','pending'))
44         ], ['product_id'], ['product_id'])
45         for move in moves:
46             product_id = move['product_id'][0]
47             res[product_id]['delivery_count'] = move['product_id_count']
48         return res
49
50     def get_product_accounts(self, cr, uid, product_id, context=None):
51         """ To get the stock input account, stock output account and stock journal related to product.
52         @param product_id: product id
53         @return: dictionary which contains information regarding stock input account, stock output account and stock journal
54         """
55         if context is None:
56             context = {}
57         product_obj = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
58
59         stock_input_acc = product_obj.property_stock_account_input and product_obj.property_stock_account_input.id or False
60         if not stock_input_acc:
61             stock_input_acc = product_obj.categ_id.property_stock_account_input_categ and product_obj.categ_id.property_stock_account_input_categ.id or False
62
63         stock_output_acc = product_obj.property_stock_account_output and product_obj.property_stock_account_output.id or False
64         if not stock_output_acc:
65             stock_output_acc = product_obj.categ_id.property_stock_account_output_categ and product_obj.categ_id.property_stock_account_output_categ.id or False
66
67         journal_id = product_obj.categ_id.property_stock_journal and product_obj.categ_id.property_stock_journal.id or False
68         account_valuation = product_obj.categ_id.property_stock_valuation_account_id and product_obj.categ_id.property_stock_valuation_account_id.id or False
69         return {
70             'stock_account_input': stock_input_acc,
71             'stock_account_output': stock_output_acc,
72             'stock_journal': journal_id,
73             'property_stock_valuation_account_id': account_valuation
74         }
75
76     def do_change_standard_price(self, cr, uid, ids, datas, context=None):
77         """ Changes the Standard Price of Product and creates an account move accordingly.
78         @param datas : dict. contain default datas like new_price, stock_output_account, stock_input_account, stock_journal
79         @param context: A standard dictionary
80         @return:
81
82         """
83         location_obj = self.pool.get('stock.location')
84         move_obj = self.pool.get('account.move')
85         move_line_obj = self.pool.get('account.move.line')
86         if context is None:
87             context = {}
88
89         new_price = datas.get('new_price', 0.0)
90         stock_output_acc = datas.get('stock_output_account', False)
91         stock_input_acc = datas.get('stock_input_account', False)
92         journal_id = datas.get('stock_journal', False)
93         move_ids = []
94         loc_ids = location_obj.search(cr, uid,[('usage','=','internal')])
95         for product in self.browse(cr, uid, ids, context=context):
96             if product.valuation != 'real_time':
97                 continue
98             account_valuation = product.categ_id.property_stock_valuation_account_id
99             account_valuation_id = account_valuation and account_valuation.id or False
100             if not account_valuation_id: raise osv.except_osv(_('Error!'), _('Specify valuation Account for Product Category: %s.') % (product.categ_id.name))
101             for location in location_obj.browse(cr, uid, loc_ids, context=context):
102                 c = context.copy()
103                 c.update({
104                     'location': location.id,
105                     'compute_child': False
106                 })
107
108                 qty = product.qty_available
109                 diff = product.standard_price - new_price
110                 if not diff: raise osv.except_osv(_('Error!'), _("No difference between standard price and new price!"))
111                 if qty:
112                     company_id = location.company_id and location.company_id.id or False
113                     if not company_id: raise osv.except_osv(_('Error!'), _('Please specify company in Location.'))
114                     #
115                     # Accounting Entries
116                     #
117                     if not journal_id:
118                         journal_id = product.categ_id.property_stock_journal and product.categ_id.property_stock_journal.id or False
119                     if not journal_id:
120                         raise osv.except_osv(_('Error!'),
121                             _('Please define journal '\
122                               'on the product category: "%s" (id: %d).') % \
123                                 (product.categ_id.name,
124                                     product.categ_id.id,))
125                     move_id = move_obj.create(cr, uid, {
126                                 'journal_id': journal_id,
127                                 'company_id': company_id
128                                 })
129
130                     move_ids.append(move_id)
131
132
133                     if diff > 0:
134                         if not stock_input_acc:
135                             stock_input_acc = product.\
136                                 property_stock_account_input.id
137                         if not stock_input_acc:
138                             stock_input_acc = product.categ_id.\
139                                     property_stock_account_input_categ.id
140                         if not stock_input_acc:
141                             raise osv.except_osv(_('Error!'),
142                                     _('Please define stock input account ' \
143                                             'for this product: "%s" (id: %d).') % \
144                                             (product.name,
145                                                 product.id,))
146                         amount_diff = qty * diff
147                         move_line_obj.create(cr, uid, {
148                                     'name': product.name,
149                                     'account_id': stock_input_acc,
150                                     'debit': amount_diff,
151                                     'move_id': move_id,
152                                     })
153                         move_line_obj.create(cr, uid, {
154                                     'name': product.categ_id.name,
155                                     'account_id': account_valuation_id,
156                                     'credit': amount_diff,
157                                     'move_id': move_id
158                                     })
159                     elif diff < 0:
160                         if not stock_output_acc:
161                             stock_output_acc = product.\
162                                 property_stock_account_output.id
163                         if not stock_output_acc:
164                             stock_output_acc = product.categ_id.\
165                                     property_stock_account_output_categ.id
166                         if not stock_output_acc:
167                             raise osv.except_osv(_('Error!'),
168                                     _('Please define stock output account ' \
169                                             'for this product: "%s" (id: %d).') % \
170                                             (product.name,
171                                                 product.id,))
172                         amount_diff = qty * -diff
173                         move_line_obj.create(cr, uid, {
174                                         'name': product.name,
175                                         'account_id': stock_output_acc,
176                                         'credit': amount_diff,
177                                         'move_id': move_id
178                                     })
179                         move_line_obj.create(cr, uid, {
180                                         'name': product.categ_id.name,
181                                         'account_id': account_valuation_id,
182                                         'debit': amount_diff,
183                                         'move_id': move_id
184                                     })
185         self.write(cr, uid, ids, {'standard_price': new_price})
186
187         return move_ids
188
189     def view_header_get(self, cr, user, view_id, view_type, context=None):
190         if context is None:
191             context = {}
192         res = super(product_product, self).view_header_get(cr, user, view_id, view_type, context)
193         if res: return res
194         if (context.get('active_id', False)) and (context.get('active_model') == 'stock.location'):
195             return _('Products: ')+self.pool.get('stock.location').browse(cr, user, context['active_id'], context).name
196         return res
197
198     def get_product_available(self, cr, uid, ids, context=None):
199         """ Finds whether product is available or not in particular warehouse.
200         @return: Dictionary of values
201         """
202         if context is None:
203             context = {}
204         
205         location_obj = self.pool.get('stock.location')
206         warehouse_obj = self.pool.get('stock.warehouse')
207         
208         states = context.get('states',[])
209         what = context.get('what',())
210         if not ids:
211             ids = self.search(cr, uid, [])
212         res = {}.fromkeys(ids, 0.0)
213         if not ids:
214             return res
215
216         if context.get('warehouse', False):
217             lot_id = warehouse_obj.read(cr, uid, int(context['warehouse']), ['lot_stock_id'])['lot_stock_id'][0]
218             if lot_id:
219                 context['location'] = lot_id
220
221         if context.get('location', False):
222             if type(context['location']) == type(1):
223                 location_ids = [context['location']]
224             elif type(context['location']) in (type(''), type(u'')):
225                 location_ids = location_obj.search(cr, uid, [('name','ilike',context['location'])], context=context)
226             else:
227                 location_ids = context['location']
228         else:
229             location_ids = []
230             wids = warehouse_obj.search(cr, uid, [], context=context)
231             if not wids:
232                 return res
233             for w in warehouse_obj.browse(cr, uid, wids, context=context):
234                 location_ids.append(w.lot_stock_id.id)
235
236         # build the list of ids of children of the location given by id
237         if context.get('compute_child',True):
238             child_location_ids = location_obj.search(cr, uid, [('location_id', 'child_of', location_ids)])
239             location_ids = child_location_ids or location_ids
240         
241         # this will be a dictionary of the product UoM by product id
242         product2uom = {}
243         uom_ids = []
244         for product in self.read(cr, uid, ids, ['uom_id'], context=context):
245             product2uom[product['id']] = product['uom_id'][0]
246             uom_ids.append(product['uom_id'][0])
247         # this will be a dictionary of the UoM resources we need for conversion purposes, by UoM id
248         uoms_o = {}
249         for uom in self.pool.get('product.uom').browse(cr, uid, uom_ids, context=context):
250             uoms_o[uom.id] = uom
251
252         results = []
253         results2 = []
254
255         from_date = context.get('from_date',False)
256         to_date = context.get('to_date',False)
257         date_str = False
258         date_values = False
259         where = [tuple(location_ids),tuple(location_ids),tuple(ids),tuple(states)]
260         if from_date and to_date:
261             date_str = "date>=%s and date<=%s"
262             where.append(tuple([from_date]))
263             where.append(tuple([to_date]))
264         elif from_date:
265             date_str = "date>=%s"
266             date_values = [from_date]
267         elif to_date:
268             date_str = "date<=%s"
269             date_values = [to_date]
270         if date_values:
271             where.append(tuple(date_values))
272
273         prodlot_id = context.get('prodlot_id', False)
274         prodlot_clause = ''
275         if prodlot_id:
276             prodlot_clause = ' and prodlot_id = %s '
277             where += [prodlot_id]
278
279         # TODO: perhaps merge in one query.
280         if 'in' in what:
281             # all moves from a location out of the set to a location in the set
282             cr.execute(
283                 'select sum(product_qty), product_id, product_uom '\
284                 'from stock_move '\
285                 'where location_id NOT IN %s '\
286                 'and location_dest_id IN %s '\
287                 'and product_id IN %s '\
288                 'and state IN %s ' + (date_str and 'and '+date_str+' ' or '') +' '\
289                 + prodlot_clause + 
290                 'group by product_id,product_uom',tuple(where))
291             results = cr.fetchall()
292         if 'out' in what:
293             # all moves from a location in the set to a location out of the set
294             cr.execute(
295                 'select sum(product_qty), product_id, product_uom '\
296                 'from stock_move '\
297                 'where location_id IN %s '\
298                 'and location_dest_id NOT IN %s '\
299                 'and product_id  IN %s '\
300                 'and state in %s ' + (date_str and 'and '+date_str+' ' or '') + ' '\
301                 + prodlot_clause + 
302                 'group by product_id,product_uom',tuple(where))
303             results2 = cr.fetchall()
304             
305         # Get the missing UoM resources
306         uom_obj = self.pool.get('product.uom')
307         uoms = map(lambda x: x[2], results) + map(lambda x: x[2], results2)
308         if context.get('uom', False):
309             uoms += [context['uom']]
310         uoms = filter(lambda x: x not in uoms_o.keys(), uoms)
311         if uoms:
312             uoms = uom_obj.browse(cr, uid, list(set(uoms)), context=context)
313             for o in uoms:
314                 uoms_o[o.id] = o
315                 
316         #TOCHECK: before change uom of product, stock move line are in old uom.
317         context.update({'raise-exception': False})
318         # Count the incoming quantities
319         for amount, prod_id, prod_uom in results:
320             amount = uom_obj._compute_qty_obj(cr, uid, uoms_o[prod_uom], amount,
321                      uoms_o[context.get('uom', False) or product2uom[prod_id]], context=context)
322             res[prod_id] += amount
323         # Count the outgoing quantities
324         for amount, prod_id, prod_uom in results2:
325             amount = uom_obj._compute_qty_obj(cr, uid, uoms_o[prod_uom], amount,
326                     uoms_o[context.get('uom', False) or product2uom[prod_id]], context=context)
327             res[prod_id] -= amount
328         return res
329
330     def _product_available(self, cr, uid, ids, field_names=None, arg=False, context=None):
331         """ Finds the incoming and outgoing quantity of product.
332         @return: Dictionary of values
333         """
334         if not field_names:
335             field_names = []
336         if context is None:
337             context = {}
338         res = {}
339         for id in ids:
340             res[id] = {}.fromkeys(field_names, 0.0)
341         for f in field_names:
342             c = context.copy()
343             if f == 'qty_available':
344                 c.update({ 'states': ('done',), 'what': ('in', 'out') })
345             if f == 'virtual_available':
346                 c.update({ 'states': ('confirmed','waiting','assigned','done'), 'what': ('in', 'out') })
347             if f == 'incoming_qty':
348                 c.update({ 'states': ('confirmed','waiting','assigned'), 'what': ('in',) })
349             if f == 'outgoing_qty':
350                 c.update({ 'states': ('confirmed','waiting','assigned'), 'what': ('out',) })
351             stock = self.get_product_available(cr, uid, ids, context=c)
352             for id in ids:
353                 res[id][f] = stock.get(id, 0.0)
354         return res
355
356     _columns = {
357         'reception_count': fields.function(_stock_move_count, string="Reception", type='integer', multi='pickings'),
358         'delivery_count': fields.function(_stock_move_count, string="Delivery", type='integer', multi='pickings'),
359         'qty_available': fields.function(_product_available, multi='qty_available',
360             type='float',  digits_compute=dp.get_precision('Product Unit of Measure'),
361             string='Quantity On Hand',
362             help="Current quantity of products.\n"
363                  "In a context with a single Stock Location, this includes "
364                  "goods stored at this Location, or any of its children.\n"
365                  "In a context with a single Warehouse, this includes "
366                  "goods stored in the Stock Location of this Warehouse, or any "
367                  "of its children.\n"
368                  "stored in the Stock Location of the Warehouse of this Shop, "
369                  "or any of its children.\n"
370                  "Otherwise, this includes goods stored in any Stock Location "
371                  "with 'internal' type."),
372         'virtual_available': fields.function(_product_available, multi='qty_available',
373             type='float',  digits_compute=dp.get_precision('Product Unit of Measure'),
374             string='Forecasted Quantity',
375             help="Forecast quantity (computed as Quantity On Hand "
376                  "- Outgoing + Incoming)\n"
377                  "In a context with a single Stock Location, this includes "
378                  "goods stored in this location, or any of its children.\n"
379                  "In a context with a single Warehouse, this includes "
380                  "goods stored in the Stock Location of this Warehouse, or any "
381                  "of its children.\n"
382                  "Otherwise, this includes goods stored in any Stock Location "
383                  "with 'internal' type."),
384         'incoming_qty': fields.function(_product_available, multi='qty_available',
385             type='float',  digits_compute=dp.get_precision('Product Unit of Measure'),
386             string='Incoming',
387             help="Quantity of products that are planned to arrive.\n"
388                  "In a context with a single Stock Location, this includes "
389                  "goods arriving to this Location, or any of its children.\n"
390                  "In a context with a single Warehouse, this includes "
391                  "goods arriving to the Stock Location of this Warehouse, or "
392                  "any of its children.\n"
393                  "Otherwise, this includes goods arriving to any Stock "
394                  "Location with 'internal' type."),
395         'outgoing_qty': fields.function(_product_available, multi='qty_available',
396             type='float',  digits_compute=dp.get_precision('Product Unit of Measure'),
397             string='Outgoing',
398             help="Quantity of products that are planned to leave.\n"
399                  "In a context with a single Stock Location, this includes "
400                  "goods leaving this Location, or any of its children.\n"
401                  "In a context with a single Warehouse, this includes "
402                  "goods leaving the Stock Location of this Warehouse, or "
403                  "any of its children.\n"
404                  "Otherwise, this includes goods leaving any Stock "
405                  "Location with 'internal' type."),
406         'track_production': fields.boolean('Track Manufacturing Lots', help="Forces to specify a Serial Number for all moves containing this product and generated by a Manufacturing Order"),
407         '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"),
408         '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"),
409         'location_id': fields.dummy(string='Location', relation='stock.location', type='many2one'),
410         'warehouse_id': fields.dummy(string='Warehouse', relation='stock.warehouse', type='many2one'),
411         'valuation':fields.selection([('manual_periodic', 'Periodical (manual)'),
412                                         ('real_time','Real Time (automated)'),], 'Inventory Valuation',
413                                         help="If real-time valuation is enabled for a product, the system will automatically write journal entries corresponding to stock moves." \
414                                              "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."
415                                         , required=True),
416     }
417
418     _defaults = {
419         'valuation': 'manual_periodic',
420     }
421
422     def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
423         res = super(product_product,self).fields_view_get(cr, uid, view_id, view_type, context, toolbar=toolbar, submenu=submenu)
424         if context is None:
425             context = {}
426         if ('location' in context) and context['location']:
427             location_info = self.pool.get('stock.location').browse(cr, uid, context['location'])
428             fields=res.get('fields',{})
429             if fields:
430                 if location_info.usage == 'supplier':
431                     if fields.get('virtual_available'):
432                         res['fields']['virtual_available']['string'] = _('Future Receptions')
433                     if fields.get('qty_available'):
434                         res['fields']['qty_available']['string'] = _('Received Qty')
435
436                 if location_info.usage == 'internal':
437                     if fields.get('virtual_available'):
438                         res['fields']['virtual_available']['string'] = _('Future Stock')
439
440                 if location_info.usage == 'customer':
441                     if fields.get('virtual_available'):
442                         res['fields']['virtual_available']['string'] = _('Future Deliveries')
443                     if fields.get('qty_available'):
444                         res['fields']['qty_available']['string'] = _('Delivered Qty')
445
446                 if location_info.usage == 'inventory':
447                     if fields.get('virtual_available'):
448                         res['fields']['virtual_available']['string'] = _('Future P&L')
449                     if fields.get('qty_available'):
450                         res['fields']['qty_available']['string'] = _('P&L Qty')
451
452                 if location_info.usage == 'procurement':
453                     if fields.get('virtual_available'):
454                         res['fields']['virtual_available']['string'] = _('Future Qty')
455                     if fields.get('qty_available'):
456                         res['fields']['qty_available']['string'] = _('Unplanned Qty')
457
458                 if location_info.usage == 'production':
459                     if fields.get('virtual_available'):
460                         res['fields']['virtual_available']['string'] = _('Future Productions')
461                     if fields.get('qty_available'):
462                         res['fields']['qty_available']['string'] = _('Produced Qty')
463         return res
464
465
466 class product_template(osv.osv):
467     _name = 'product.template'
468     _inherit = 'product.template'
469     _columns = {
470         'property_stock_procurement': fields.property(
471             type='many2one',
472             relation='stock.location',
473             string="Procurement Location",
474             domain=[('usage','like','procurement')],
475             help="This stock location will be used, instead of the default one, as the source location for stock moves generated by procurements."),
476         'property_stock_production': fields.property(
477             type='many2one',
478             relation='stock.location',
479             string="Production Location",
480             domain=[('usage','like','production')],
481             help="This stock location will be used, instead of the default one, as the source location for stock moves generated by manufacturing orders."),
482         'property_stock_inventory': fields.property(
483             type='many2one',
484             relation='stock.location',
485             string="Inventory Location",
486             domain=[('usage','like','inventory')],
487             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."),
488         'property_stock_account_input': fields.property(
489             type='many2one',
490             relation='account.account',
491             string='Stock Input Account',
492             help="When doing real-time inventory valuation, counterpart journal items for all incoming stock moves will be posted in this account, unless "
493                  "there is a specific valuation account set on the source location. When not set on the product, the one from the product category is used."),
494         'property_stock_account_output': fields.property(
495             type='many2one',
496             relation='account.account',
497             string='Stock Output Account',
498             help="When doing real-time inventory valuation, counterpart journal items for all outgoing stock moves will be posted in this account, unless "
499                  "there is a specific valuation account set on the destination location. When not set on the product, the one from the product category is used."),
500         '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."),
501         'loc_rack': fields.char('Rack', size=16),
502         'loc_row': fields.char('Row', size=16),
503         'loc_case': fields.char('Case', size=16),
504     }
505
506     _defaults = {
507         'sale_delay': 7,
508     }
509
510 class product_category(osv.osv):
511
512     _inherit = 'product.category'
513     _columns = {
514         'property_stock_journal': fields.property(
515             relation='account.journal',
516             type='many2one',
517             string='Stock Journal',
518             help="When doing real-time inventory valuation, this is the Accounting Journal in which entries will be automatically posted when stock moves are processed."),
519         'property_stock_account_input_categ': fields.property(
520             type='many2one',
521             relation='account.account',
522             string='Stock Input Account',
523             help="When doing real-time inventory valuation, counterpart journal items for all incoming stock moves will be posted in this account, unless "
524                  "there is a specific valuation account set on the source location. This is the default value for all products in this category. It "
525                  "can also directly be set on each product"),
526         'property_stock_account_output_categ': fields.property(
527             type='many2one',
528             relation='account.account',
529             string='Stock Output Account',
530             help="When doing real-time inventory valuation, counterpart journal items for all outgoing stock moves will be posted in this account, unless "
531                  "there is a specific valuation account set on the destination location. This is the default value for all products in this category. It "
532                  "can also directly be set on each product"),
533         'property_stock_valuation_account_id': fields.property(
534             type='many2one',
535             relation='account.account',
536             string="Stock Valuation Account",
537             help="When real-time inventory valuation is enabled on a product, this account will hold the current value of the products.",),
538     }
539
540
541 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: