[FIX] stock: improve speed of product's stock quantity/value calculation
[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 osv import fields, osv
23 from tools.translate import _
24 import decimal_precision as dp
25
26 class product_product(osv.osv):
27     _inherit = "product.product"
28
29     def get_product_accounts(self, cr, uid, product_id, context=None):
30         """ To get the stock input account, stock output account and stock journal related to product.
31         @param product_id: product id
32         @return: dictionary which contains information regarding stock input account, stock output account and stock journal
33         """
34         if context is None:
35             context = {}
36         product_obj = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
37
38         stock_input_acc = product_obj.property_stock_account_input and product_obj.property_stock_account_input.id or False
39         if not stock_input_acc:
40             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
41
42         stock_output_acc = product_obj.property_stock_account_output and product_obj.property_stock_account_output.id or False
43         if not stock_output_acc:
44             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
45
46         journal_id = product_obj.categ_id.property_stock_journal and product_obj.categ_id.property_stock_journal.id or False
47         account_variation = product_obj.categ_id.property_stock_variation and product_obj.categ_id.property_stock_variation.id or False
48
49         return {
50             'stock_account_input': stock_input_acc,
51             'stock_account_output': stock_output_acc,
52             'stock_journal': journal_id,
53             'property_stock_variation': account_variation
54         }
55
56     def do_change_standard_price(self, cr, uid, ids, datas, context=None):
57         """ Changes the Standard Price of Product and creates an account move accordingly.
58         @param datas : dict. contain default datas like new_price, stock_output_account, stock_input_account, stock_journal
59         @param context: A standard dictionary
60         @return:
61
62         """
63         location_obj = self.pool.get('stock.location')
64         move_obj = self.pool.get('account.move')
65         move_line_obj = self.pool.get('account.move.line')
66         if context is None:
67             context = {}
68
69         new_price = datas.get('new_price', 0.0)
70         stock_output_acc = datas.get('stock_output_account', False)
71         stock_input_acc = datas.get('stock_input_account', False)
72         journal_id = datas.get('stock_journal', False)
73         product_obj=self.browse(cr, uid, ids, context=context)[0]
74         account_variation = product_obj.categ_id.property_stock_variation
75         account_variation_id = account_variation and account_variation.id or False
76         if not account_variation_id: raise osv.except_osv(_('Error!'), _('Variation Account is not specified for Product Category: %s') % (product_obj.categ_id.name))
77         move_ids = []
78         loc_ids = location_obj.search(cr, uid,[('usage','=','internal')])
79         for rec_id in ids:
80             for location in location_obj.browse(cr, uid, loc_ids, context=context):
81                 c = context.copy()
82                 c.update({
83                     'location': location.id,
84                     'compute_child': False
85                 })
86
87                 product = self.browse(cr, uid, rec_id, context=c)
88                 qty = product.qty_available
89                 diff = product.standard_price - new_price
90                 if not diff: raise osv.except_osv(_('Error!'), _("Could not find any difference between standard price and new price!"))
91                 if qty:
92                     company_id = location.company_id and location.company_id.id or False
93                     if not company_id: raise osv.except_osv(_('Error!'), _('Company is not specified in Location'))
94                     #
95                     # Accounting Entries
96                     #
97                     if not journal_id:
98                         journal_id = product.categ_id.property_stock_journal and product.categ_id.property_stock_journal.id or False
99                     if not journal_id:
100                         raise osv.except_osv(_('Error!'),
101                             _('There is no journal defined '\
102                                 'on the product category: "%s" (id: %d)') % \
103                                 (product.categ_id.name,
104                                     product.categ_id.id,))
105                     move_id = move_obj.create(cr, uid, {
106                                 'journal_id': journal_id,
107                                 'company_id': company_id
108                                 })
109
110                     move_ids.append(move_id)
111
112
113                     if diff > 0:
114                         if not stock_input_acc:
115                             stock_input_acc = product.product_tmpl_id.\
116                                 property_stock_account_input.id
117                         if not stock_input_acc:
118                             stock_input_acc = product.categ_id.\
119                                     property_stock_account_input_categ.id
120                         if not stock_input_acc:
121                             raise osv.except_osv(_('Error!'),
122                                     _('There is no stock input account defined ' \
123                                             'for this product: "%s" (id: %d)') % \
124                                             (product.name,
125                                                 product.id,))
126                         amount_diff = qty * diff
127                         move_line_obj.create(cr, uid, {
128                                     'name': product.name,
129                                     'account_id': stock_input_acc,
130                                     'debit': amount_diff,
131                                     'move_id': move_id,
132                                     })
133                         move_line_obj.create(cr, uid, {
134                                     'name': product.categ_id.name,
135                                     'account_id': account_variation_id,
136                                     'credit': amount_diff,
137                                     'move_id': move_id
138                                     })
139                     elif diff < 0:
140                         if not stock_output_acc:
141                             stock_output_acc = product.product_tmpl_id.\
142                                 property_stock_account_output.id
143                         if not stock_output_acc:
144                             stock_output_acc = product.categ_id.\
145                                     property_stock_account_output_categ.id
146                         if not stock_output_acc:
147                             raise osv.except_osv(_('Error!'),
148                                     _('There is no stock output account defined ' \
149                                             'for this product: "%s" (id: %d)') % \
150                                             (product.name,
151                                                 product.id,))
152                         amount_diff = qty * -diff
153                         move_line_obj.create(cr, uid, {
154                                         'name': product.name,
155                                         'account_id': stock_output_acc,
156                                         'credit': amount_diff,
157                                         'move_id': move_id
158                                     })
159                         move_line_obj.create(cr, uid, {
160                                         'name': product.categ_id.name,
161                                         'account_id': account_variation_id,
162                                         'debit': amount_diff,
163                                         'move_id': move_id
164                                     })
165
166             self.write(cr, uid, rec_id, {'standard_price': new_price})
167
168         return move_ids
169
170     def view_header_get(self, cr, user, view_id, view_type, context=None):
171         if context is None:
172             context = {}
173         res = super(product_product, self).view_header_get(cr, user, view_id, view_type, context)
174         if res: return res
175         if (context.get('active_id', False)) and (context.get('active_model') == 'stock.location'):
176             return _('Products: ')+self.pool.get('stock.location').browse(cr, user, context['active_id'], context).name
177         return res
178
179     def get_product_available(self, cr, uid, ids, context=None):
180         """ Finds whether product is available or not in particular warehouse.
181         @return: Dictionary of values
182         """
183         if context is None:
184             context = {}
185         states = context.get('states',[])
186         what = context.get('what',())
187         if not ids:
188             ids = self.search(cr, uid, [])
189         res = {}.fromkeys(ids, 0.0)
190         if not ids:
191             return res
192
193     # TODO: write in more ORM way, less queries, more pg84 magic
194         if context.get('shop', False):
195             cr.execute('select warehouse_id from sale_shop where id=%s', (int(context['shop']),))
196             res2 = cr.fetchone()
197             if res2:
198                 context['warehouse'] = res2[0]
199
200         if context.get('warehouse', False):
201             cr.execute('select lot_stock_id from stock_warehouse where id=%s', (int(context['warehouse']),))
202             res2 = cr.fetchone()
203             if res2:
204                 context['location'] = res2[0]
205
206         if context.get('location', False):
207             if type(context['location']) == type(1):
208                 location_ids = [context['location']]
209             elif type(context['location']) in (type(''), type(u'')):
210                 location_ids = self.pool.get('stock.location').search(cr, uid, [('name','ilike',context['location'])], context=context)
211             else:
212                 location_ids = context['location']
213         else:
214             location_ids = []
215             wids = self.pool.get('stock.warehouse').search(cr, uid, [], context=context)
216             for w in self.pool.get('stock.warehouse').browse(cr, uid, wids, context=context):
217                 location_ids.append(w.lot_stock_id.id)
218
219         # build the list of ids of children of the location given by id
220         if context.get('compute_child',True):
221             child_location_ids = self.pool.get('stock.location').search(cr, uid, [('location_id', 'child_of', location_ids)])
222             location_ids = child_location_ids or location_ids
223         else:
224             location_ids = location_ids
225
226         uoms_o = {}
227         uoms_ids = {}
228         product2uom = {}
229         for product in self.read(cr, uid, ids, ['uom_id'], context=context):
230             product_uom_id = product['uom_id'][0] # uom id is required!
231             product2uom[product['id']] = product_uom_id
232             uoms_ids[product_uom_id] = True
233
234         uom_obj = self.pool.get('product.uom')
235         for uom in uom_obj.browse(cr, uid, uoms_ids.keys(), context=context):
236             uoms_o[uom.id] = uom
237
238         results = []
239         results2 = []
240
241         from_date = context.get('from_date',False)
242         to_date = context.get('to_date',False)
243         date_str = False
244         date_values = False
245         where = [tuple(location_ids),tuple(location_ids),tuple(ids),tuple(states)]
246         if from_date and to_date:
247             date_str = "date>=%s and date<=%s"
248             where.append(tuple([from_date]))
249             where.append(tuple([to_date]))
250         elif from_date:
251             date_str = "date>=%s"
252             date_values = [from_date]
253         elif to_date:
254             date_str = "date<=%s"
255             date_values = [to_date]
256
257         prodlot_id = context.get('prodlot_id', False)
258
259     # TODO: perhaps merge in one query.
260         if date_values:
261             where.append(tuple(date_values))
262         if 'in' in what:
263             # all moves from a location out of the set to a location in the set
264             cr.execute(
265                 'select sum(product_qty), product_id, product_uom '\
266                 'from stock_move '\
267                 'where location_id NOT IN %s '\
268                 'and location_dest_id IN %s '\
269                 'and product_id IN %s '\
270                 '' + (prodlot_id and ('and prodlot_id = ' + str(prodlot_id)) or '') + ' '\
271                 'and state IN %s ' + (date_str and 'and '+date_str+' ' or '') +' '\
272                 'group by product_id,product_uom',tuple(where))
273             results = cr.fetchall()
274         if 'out' in what:
275             # all moves from a location in the set to a location out of the set
276             cr.execute(
277                 'select sum(product_qty), product_id, product_uom '\
278                 'from stock_move '\
279                 'where location_id IN %s '\
280                 'and location_dest_id NOT IN %s '\
281                 'and product_id  IN %s '\
282                 '' + (prodlot_id and ('and prodlot_id = ' + str(prodlot_id)) or '') + ' '\
283                 'and state in %s ' + (date_str and 'and '+date_str+' ' or '') + ' '\
284                 'group by product_id,product_uom',tuple(where))
285             results2 = cr.fetchall()
286         uom_obj = self.pool.get('product.uom')
287         uoms = map(lambda x: x[2], results) + map(lambda x: x[2], results2)
288         if context.get('uom', False):
289             uoms += [context['uom']]
290
291         uoms = filter(lambda x: x not in uoms_o.keys(), uoms)
292         if uoms:
293             uoms = uom_obj.browse(cr, uid, list(set(uoms)), context=context)
294             for o in uoms:
295                 uoms_o[o.id] = o
296         #TOCHECK: before change uom of product, stock move line are in old uom.
297         context.update({'raise-exception': False})
298         for amount, prod_id, prod_uom in results:
299             amount = uom_obj._compute_qty_obj(cr, uid, uoms_o[prod_uom], amount,
300                      uoms_o[context.get('uom', False) or product2uom[prod_id]], context=context)
301             res[prod_id] += amount
302         for amount, prod_id, prod_uom in results2:
303             amount = uom_obj._compute_qty_obj(cr, uid, uoms_o[prod_uom], amount,
304                     uoms_o[context.get('uom', False) or product2uom[prod_id]], context=context)
305             res[prod_id] -= amount
306         return res
307
308     def _product_available(self, cr, uid, ids, field_names=None, arg=False, context=None):
309         """ Finds the incoming and outgoing quantity of product.
310         @return: Dictionary of values
311         """
312         if not field_names:
313             field_names = []
314         if context is None:
315             context = {}
316         res = {}
317         for id in ids:
318             res[id] = {}.fromkeys(field_names, 0.0)
319         for f in field_names:
320             c = context.copy()
321             if f == 'qty_available':
322                 c.update({ 'states': ('done',), 'what': ('in', 'out') })
323             if f == 'virtual_available':
324                 c.update({ 'states': ('confirmed','waiting','assigned','done'), 'what': ('in', 'out') })
325             if f == 'incoming_qty':
326                 c.update({ 'states': ('confirmed','waiting','assigned'), 'what': ('in',) })
327             if f == 'outgoing_qty':
328                 c.update({ 'states': ('confirmed','waiting','assigned'), 'what': ('out',) })
329             stock = self.get_product_available(cr, uid, ids, context=c)
330             for id in ids:
331                 res[id][f] = stock.get(id, 0.0)
332         return res
333
334     _columns = {
335         'qty_available': fields.function(_product_available, method=True, type='float', string='Real Stock', help="Current quantities of products in selected locations or all internal if none have been selected.", multi='qty_available', digits_compute=dp.get_precision('Product UoM')),
336         'virtual_available': fields.function(_product_available, method=True, type='float', string='Virtual Stock', help="Future stock for this product according to the selected locations or all internal if none have been selected. Computed as: Real Stock - Outgoing + Incoming.", multi='qty_available', digits_compute=dp.get_precision('Product UoM')),
337         'incoming_qty': fields.function(_product_available, method=True, type='float', string='Incoming', help="Quantities of products that are planned to arrive in selected locations or all internal if none have been selected.", multi='qty_available', digits_compute=dp.get_precision('Product UoM')),
338         'outgoing_qty': fields.function(_product_available, method=True, type='float', string='Outgoing', help="Quantities of products that are planned to leave in selected locations or all internal if none have been selected.", multi='qty_available', digits_compute=dp.get_precision('Product UoM')),
339         'track_production': fields.boolean('Track Manufacturing Lots' , help="Forces to specify a Production Lot for all moves containing this product and generated by a Manufacturing Order"),
340         'track_incoming': fields.boolean('Track Incoming Lots', help="Forces to specify a Production Lot for all moves containing this product and coming from a Supplier Location"),
341         'track_outgoing': fields.boolean('Track Outgoing Lots', help="Forces to specify a Production Lot for all moves containing this product and going to a Customer Location"),
342         'location_id': fields.dummy(string='Stock Location', relation='stock.location', type='many2one'),
343         'valuation':fields.selection([('manual_periodic', 'Periodical (manual)'),
344                                         ('real_time','Real Time (automated)'),], 'Inventory Valuation',
345                                         help="If real-time valuation is enabled for a product, the system will automatically write journal entries corresponding to stock moves." \
346                                              "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."
347                                         , required=True),
348     }
349
350     _defaults = {
351         'valuation': lambda *a: 'manual_periodic',
352     }
353
354     def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
355         res = super(product_product,self).fields_view_get(cr, uid, view_id, view_type, context, toolbar=toolbar, submenu=submenu)
356         if context is None:
357             context = {}
358         if ('location' in context) and context['location']:
359             location_info = self.pool.get('stock.location').browse(cr, uid, context['location'])
360             fields=res.get('fields',{})
361             if fields:
362                 if location_info.usage == 'supplier':
363                     if fields.get('virtual_available'):
364                         res['fields']['virtual_available']['string'] = _('Future Receptions')
365                     if fields.get('qty_available'):
366                         res['fields']['qty_available']['string'] = _('Received Qty')
367
368                 if location_info.usage == 'internal':
369                     if fields.get('virtual_available'):
370                         res['fields']['virtual_available']['string'] = _('Future Stock')
371
372                 if location_info.usage == 'customer':
373                     if fields.get('virtual_available'):
374                         res['fields']['virtual_available']['string'] = _('Future Deliveries')
375                     if fields.get('qty_available'):
376                         res['fields']['qty_available']['string'] = _('Delivered Qty')
377
378                 if location_info.usage == 'inventory':
379                     if fields.get('virtual_available'):
380                         res['fields']['virtual_available']['string'] = _('Future P&L')
381                     if fields.get('qty_available'):
382                         res['fields']['qty_available']['string'] = _('P&L Qty')
383
384                 if location_info.usage == 'procurement':
385                     if fields.get('virtual_available'):
386                         res['fields']['virtual_available']['string'] = _('Future Qty')
387                     if fields.get('qty_available'):
388                         res['fields']['qty_available']['string'] = _('Unplanned Qty')
389
390                 if location_info.usage == 'production':
391                     if fields.get('virtual_available'):
392                         res['fields']['virtual_available']['string'] = _('Future Productions')
393                     if fields.get('qty_available'):
394                         res['fields']['qty_available']['string'] = _('Produced Qty')
395         return res
396
397 product_product()
398
399 class product_template(osv.osv):
400     _name = 'product.template'
401     _inherit = 'product.template'
402     _columns = {
403         'property_stock_procurement': fields.property(
404             'stock.location',
405             type='many2one',
406             relation='stock.location',
407             string="Procurement Location",
408             method=True,
409             view_load=True,
410             domain=[('usage','like','procurement')],
411             help="For the current product, this stock location will be used, instead of the default one, as the source location for stock moves generated by procurements"),
412         'property_stock_production': fields.property(
413             'stock.location',
414             type='many2one',
415             relation='stock.location',
416             string="Production Location",
417             method=True,
418             view_load=True,
419             domain=[('usage','like','production')],
420             help="For the current product, this stock location will be used, instead of the default one, as the source location for stock moves generated by production orders"),
421         'property_stock_inventory': fields.property(
422             'stock.location',
423             type='many2one',
424             relation='stock.location',
425             string="Inventory Location",
426             method=True,
427             view_load=True,
428             domain=[('usage','like','inventory')],
429             help="For the current product, this stock location will be used, instead of the default one, as the source location for stock moves generated when you do an inventory"),
430         'property_stock_account_input': fields.property('account.account',
431             type='many2one', relation='account.account',
432             string='Stock Input Account', method=True, view_load=True,
433             help='When doing real-time inventory valuation, counterpart Journal Items for all incoming stock moves will be posted in this account. If not set on the product, the one from the product category is used.'),
434         'property_stock_account_output': fields.property('account.account',
435             type='many2one', relation='account.account',
436             string='Stock Output Account', method=True, view_load=True,
437             help='When doing real-time inventory valuation, counterpart Journal Items for all outgoing stock moves will be posted in this account. If not set on the product, the one from the product category is used.'),
438     }
439
440 product_template()
441
442 class product_category(osv.osv):
443
444     _inherit = 'product.category'
445     _columns = {
446         'property_stock_journal': fields.property('account.journal',
447             relation='account.journal', type='many2one',
448             string='Stock journal', method=True, view_load=True,
449             help="When doing real-time inventory valuation, this is the Accounting Journal in which entries will be automatically posted when stock moves are processed."),
450         'property_stock_account_input_categ': fields.property('account.account',
451             type='many2one', relation='account.account',
452             string='Stock Input Account', method=True, view_load=True,
453             help='When doing real-time inventory valuation, counterpart Journal Items for all incoming stock moves will be posted in this account. This is the default value for all products in this category, it can also directly be set on each product.'),
454         'property_stock_account_output_categ': fields.property('account.account',
455             type='many2one', relation='account.account',
456             string='Stock Output Account', method=True, view_load=True,
457             help='When doing real-time inventory valuation, counterpart Journal Items for all outgoing stock moves will be posted in this account. This is the default value for all products in this category, it can also directly be set on each product.'),
458         'property_stock_variation': fields.property('account.account',
459             type='many2one',
460             relation='account.account',
461             string="Stock Variation Account",
462             method=True, view_load=True,
463             help="When real-time inventory valuation is enabled on a product, this account will hold the current value of the products.",),
464     }
465
466 product_category()
467
468 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: