[MERGE]sync with trunk
[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         product_obj=self.browse(cr, uid, ids, context=context)[0]
94         account_valuation = product_obj.categ_id.property_stock_valuation_account_id
95         account_valuation_id = account_valuation and account_valuation.id or False
96         if not account_valuation_id: raise osv.except_osv(_('Error!'), _('Specify valuation Account for Product Category: %s.') % (product_obj.categ_id.name))
97         move_ids = []
98         loc_ids = location_obj.search(cr, uid,[('usage','=','internal')])
99         for rec_id in ids:
100             for location in location_obj.browse(cr, uid, loc_ids, context=context):
101                 c = context.copy()
102                 c.update({
103                     'location': location.id,
104                     'compute_child': False
105                 })
106
107                 product = self.browse(cr, uid, rec_id, context=c)
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
186             self.write(cr, uid, rec_id, {'standard_price': new_price})
187
188         return move_ids
189
190     def view_header_get(self, cr, user, view_id, view_type, context=None):
191         if context is None:
192             context = {}
193         res = super(product_product, self).view_header_get(cr, user, view_id, view_type, context)
194         if res: return res
195         if (context.get('active_id', False)) and (context.get('active_model') == 'stock.location'):
196             return _('Products: ')+self.pool.get('stock.location').browse(cr, user, context['active_id'], context).name
197         return res
198
199     def get_product_available(self, cr, uid, ids, context=None):
200         """ Finds whether product is available or not in particular warehouse.
201         @return: Dictionary of values
202         """
203         if context is None:
204             context = {}
205         
206         location_obj = self.pool.get('stock.location')
207         warehouse_obj = self.pool.get('stock.warehouse')
208         
209         states = context.get('states',[])
210         what = context.get('what',())
211         if not ids:
212             ids = self.search(cr, uid, [])
213         res = {}.fromkeys(ids, 0.0)
214         if not ids:
215             return res
216
217         if context.get('warehouse', False):
218             lot_id = warehouse_obj.read(cr, uid, int(context['warehouse']), ['lot_stock_id'])['lot_stock_id'][0]
219             if lot_id:
220                 context['location'] = lot_id
221
222         if context.get('location', False):
223             if type(context['location']) == type(1):
224                 location_ids = [context['location']]
225             elif type(context['location']) in (type(''), type(u'')):
226                 location_ids = location_obj.search(cr, uid, [('name','ilike',context['location'])], context=context)
227             else:
228                 location_ids = context['location']
229         else:
230             location_ids = []
231             wids = warehouse_obj.search(cr, uid, [], context=context)
232             if not wids:
233                 return res
234             for w in warehouse_obj.browse(cr, uid, wids, context=context):
235                 location_ids.append(w.lot_stock_id.id)
236
237         # build the list of ids of children of the location given by id
238         if context.get('compute_child',True):
239             child_location_ids = location_obj.search(cr, uid, [('location_id', 'child_of', location_ids)])
240             location_ids = child_location_ids or location_ids
241         
242         # this will be a dictionary of the product UoM by product id
243         product2uom = {}
244         uom_ids = []
245         for product in self.read(cr, uid, ids, ['uom_id'], context=context):
246             product2uom[product['id']] = product['uom_id'][0]
247             uom_ids.append(product['uom_id'][0])
248         # this will be a dictionary of the UoM resources we need for conversion purposes, by UoM id
249         uoms_o = {}
250         for uom in self.pool.get('product.uom').browse(cr, uid, uom_ids, context=context):
251             uoms_o[uom.id] = uom
252
253         results = []
254         results2 = []
255
256         from_date = context.get('from_date',False)
257         to_date = context.get('to_date',False)
258         date_str = False
259         date_values = False
260         where = [tuple(location_ids),tuple(location_ids),tuple(ids),tuple(states)]
261         if from_date and to_date:
262             date_str = "date>=%s and date<=%s"
263             where.append(tuple([from_date]))
264             where.append(tuple([to_date]))
265         elif from_date:
266             date_str = "date>=%s"
267             date_values = [from_date]
268         elif to_date:
269             date_str = "date<=%s"
270             date_values = [to_date]
271         if date_values:
272             where.append(tuple(date_values))
273
274         prodlot_id = context.get('prodlot_id', False)
275         prodlot_clause = ''
276         if prodlot_id:
277             prodlot_clause = ' and prodlot_id = %s '
278             where += [prodlot_id]
279
280         # TODO: perhaps merge in one query.
281         if 'in' in what:
282             # all moves from a location out of the set to a location in the set
283             cr.execute(
284                 'select sum(product_qty), product_id, product_uom '\
285                 'from stock_move '\
286                 'where location_id NOT IN %s '\
287                 'and location_dest_id IN %s '\
288                 'and product_id IN %s '\
289                 'and state IN %s ' + (date_str and 'and '+date_str+' ' or '') +' '\
290                 + prodlot_clause + 
291                 'group by product_id,product_uom',tuple(where))
292             results = cr.fetchall()
293         if 'out' in what:
294             # all moves from a location in the set to a location out of the set
295             cr.execute(
296                 'select sum(product_qty), product_id, product_uom '\
297                 'from stock_move '\
298                 'where location_id IN %s '\
299                 'and location_dest_id NOT IN %s '\
300                 'and product_id  IN %s '\
301                 'and state in %s ' + (date_str and 'and '+date_str+' ' or '') + ' '\
302                 + prodlot_clause + 
303                 'group by product_id,product_uom',tuple(where))
304             results2 = cr.fetchall()
305             
306         # Get the missing UoM resources
307         uom_obj = self.pool.get('product.uom')
308         uoms = map(lambda x: x[2], results) + map(lambda x: x[2], results2)
309         if context.get('uom', False):
310             uoms += [context['uom']]
311         uoms = filter(lambda x: x not in uoms_o.keys(), uoms)
312         if uoms:
313             uoms = uom_obj.browse(cr, uid, list(set(uoms)), context=context)
314             for o in uoms:
315                 uoms_o[o.id] = o
316                 
317         #TOCHECK: before change uom of product, stock move line are in old uom.
318         context.update({'raise-exception': False})
319         # Count the incoming quantities
320         for amount, prod_id, prod_uom in results:
321             amount = uom_obj._compute_qty_obj(cr, uid, uoms_o[prod_uom], amount,
322                      uoms_o[context.get('uom', False) or product2uom[prod_id]], context=context)
323             res[prod_id] += amount
324         # Count the outgoing quantities
325         for amount, prod_id, prod_uom in results2:
326             amount = uom_obj._compute_qty_obj(cr, uid, uoms_o[prod_uom], amount,
327                     uoms_o[context.get('uom', False) or product2uom[prod_id]], context=context)
328             res[prod_id] -= amount
329         return res
330
331     def _product_available(self, cr, uid, ids, field_names=None, arg=False, context=None):
332         """ Finds the incoming and outgoing quantity of product.
333         @return: Dictionary of values
334         """
335         if not field_names:
336             field_names = []
337         if context is None:
338             context = {}
339         res = {}
340         for id in ids:
341             res[id] = {}.fromkeys(field_names, 0.0)
342         for f in field_names:
343             c = context.copy()
344             if f == 'qty_available':
345                 c.update({ 'states': ('done',), 'what': ('in', 'out') })
346             if f == 'virtual_available':
347                 c.update({ 'states': ('confirmed','waiting','assigned','done'), 'what': ('in', 'out') })
348             if f == 'incoming_qty':
349                 c.update({ 'states': ('confirmed','waiting','assigned'), 'what': ('in',) })
350             if f == 'outgoing_qty':
351                 c.update({ 'states': ('confirmed','waiting','assigned'), 'what': ('out',) })
352             stock = self.get_product_available(cr, uid, ids, context=c)
353             for id in ids:
354                 res[id][f] = stock.get(id, 0.0)
355         return res
356
357     _columns = {
358         'reception_count': fields.function(_stock_move_count, string="Reception", type='integer', multi='pickings'),
359         'delivery_count': fields.function(_stock_move_count, string="Delivery", type='integer', multi='pickings'),
360         'qty_available': fields.function(_product_available, multi='qty_available',
361             type='float',  digits_compute=dp.get_precision('Product Unit of Measure'),
362             string='Quantity On Hand',
363             help="Current quantity of products.\n"
364                  "In a context with a single Stock Location, this includes "
365                  "goods stored at this Location, or any of its children.\n"
366                  "In a context with a single Warehouse, this includes "
367                  "goods stored in the Stock Location of this Warehouse, or any "
368                  "of its children.\n"
369                  "stored in the Stock Location of the Warehouse of this Shop, "
370                  "or any of its children.\n"
371                  "Otherwise, this includes goods stored in any Stock Location "
372                  "with 'internal' type."),
373         'virtual_available': fields.function(_product_available, multi='qty_available',
374             type='float',  digits_compute=dp.get_precision('Product Unit of Measure'),
375             string='Forecasted Quantity',
376             help="Forecast quantity (computed as Quantity On Hand "
377                  "- Outgoing + Incoming)\n"
378                  "In a context with a single Stock Location, this includes "
379                  "goods stored in this location, or any of its children.\n"
380                  "In a context with a single Warehouse, this includes "
381                  "goods stored in the Stock Location of this Warehouse, or any "
382                  "of its children.\n"
383                  "stored in the Stock Location of the Warehouse of this Shop, "
384                  "or any of its children.\n"
385                  "Otherwise, this includes goods stored in any Stock Location "
386                  "with 'internal' type."),
387         'incoming_qty': fields.function(_product_available, multi='qty_available',
388             type='float',  digits_compute=dp.get_precision('Product Unit of Measure'),
389             string='Incoming',
390             help="Quantity of products that are planned to arrive.\n"
391                  "In a context with a single Stock Location, this includes "
392                  "goods arriving to this Location, or any of its children.\n"
393                  "In a context with a single Warehouse, this includes "
394                  "goods arriving to the Stock Location of this Warehouse, or "
395                  "any of its children.\n"
396                  "In a context with a single Shop, this includes goods "
397                  "arriving to the Stock Location of the Warehouse of this "
398                  "Shop, or any of its children.\n"
399                  "Otherwise, this includes goods arriving to any Stock "
400                  "Location with 'internal' type."),
401         'outgoing_qty': fields.function(_product_available, multi='qty_available',
402             type='float',  digits_compute=dp.get_precision('Product Unit of Measure'),
403             string='Outgoing',
404             help="Quantity of products that are planned to leave.\n"
405                  "In a context with a single Stock Location, this includes "
406                  "goods leaving this Location, or any of its children.\n"
407                  "In a context with a single Warehouse, this includes "
408                  "goods leaving the Stock Location of this Warehouse, or "
409                  "any of its children.\n"
410                  "In a context with a single Shop, this includes goods "
411                  "leaving the Stock Location of the Warehouse of this "
412                  "Shop, or any of its children.\n"
413                  "Otherwise, this includes goods leaving any Stock "
414                  "Location with 'internal' type."),
415         '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"),
416         '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"),
417         '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"),
418         'location_id': fields.dummy(string='Location', relation='stock.location', type='many2one'),
419         'warehouse_id': fields.dummy(string='Warehouse', relation='stock.warehouse', type='many2one'),
420         'valuation':fields.selection([('manual_periodic', 'Periodical (manual)'),
421                                         ('real_time','Real Time (automated)'),], 'Inventory Valuation',
422                                         help="If real-time valuation is enabled for a product, the system will automatically write journal entries corresponding to stock moves." \
423                                              "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."
424                                         , required=True),
425     }
426
427     _defaults = {
428         'valuation': 'manual_periodic',
429     }
430
431     def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
432         res = super(product_product,self).fields_view_get(cr, uid, view_id, view_type, context, toolbar=toolbar, submenu=submenu)
433         if context is None:
434             context = {}
435         if ('location' in context) and context['location']:
436             location_info = self.pool.get('stock.location').browse(cr, uid, context['location'])
437             fields=res.get('fields',{})
438             if fields:
439                 if location_info.usage == 'supplier':
440                     if fields.get('virtual_available'):
441                         res['fields']['virtual_available']['string'] = _('Future Receptions')
442                     if fields.get('qty_available'):
443                         res['fields']['qty_available']['string'] = _('Received Qty')
444
445                 if location_info.usage == 'internal':
446                     if fields.get('virtual_available'):
447                         res['fields']['virtual_available']['string'] = _('Future Stock')
448
449                 if location_info.usage == 'customer':
450                     if fields.get('virtual_available'):
451                         res['fields']['virtual_available']['string'] = _('Future Deliveries')
452                     if fields.get('qty_available'):
453                         res['fields']['qty_available']['string'] = _('Delivered Qty')
454
455                 if location_info.usage == 'inventory':
456                     if fields.get('virtual_available'):
457                         res['fields']['virtual_available']['string'] = _('Future P&L')
458                     if fields.get('qty_available'):
459                         res['fields']['qty_available']['string'] = _('P&L Qty')
460
461                 if location_info.usage == 'procurement':
462                     if fields.get('virtual_available'):
463                         res['fields']['virtual_available']['string'] = _('Future Qty')
464                     if fields.get('qty_available'):
465                         res['fields']['qty_available']['string'] = _('Unplanned Qty')
466
467                 if location_info.usage == 'production':
468                     if fields.get('virtual_available'):
469                         res['fields']['virtual_available']['string'] = _('Future Productions')
470                     if fields.get('qty_available'):
471                         res['fields']['qty_available']['string'] = _('Produced Qty')
472         return res
473
474 product_product()
475
476 class product_template(osv.osv):
477     _name = 'product.template'
478     _inherit = 'product.template'
479     _columns = {
480         'property_stock_procurement': fields.property(
481             'stock.location',
482             type='many2one',
483             relation='stock.location',
484             string="Procurement Location",
485             view_load=True,
486             domain=[('usage','like','procurement')],
487             help="This stock location will be used, instead of the default one, as the source location for stock moves generated by procurements."),
488         'property_stock_production': fields.property(
489             'stock.location',
490             type='many2one',
491             relation='stock.location',
492             string="Production Location",
493             view_load=True,
494             domain=[('usage','like','production')],
495             help="This stock location will be used, instead of the default one, as the source location for stock moves generated by manufacturing orders."),
496         'property_stock_inventory': fields.property(
497             'stock.location',
498             type='many2one',
499             relation='stock.location',
500             string="Inventory Location",
501             view_load=True,
502             domain=[('usage','like','inventory')],
503             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."),
504         'property_stock_account_input': fields.property('account.account',
505             type='many2one', relation='account.account',
506             string='Stock Input Account', view_load=True,
507             help="When doing real-time inventory valuation, counterpart journal items for all incoming stock moves will be posted in this account, unless "
508                  "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."),
509         'property_stock_account_output': fields.property('account.account',
510             type='many2one', relation='account.account',
511             string='Stock Output Account', view_load=True,
512             help="When doing real-time inventory valuation, counterpart journal items for all outgoing stock moves will be posted in this account, unless "
513                  "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."),
514         '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."),
515         'loc_rack': fields.char('Rack', size=16),
516         'loc_row': fields.char('Row', size=16),
517         'loc_case': fields.char('Case', size=16),
518     }
519
520     _defaults = {
521         'sale_delay': 7,
522     }
523 product_template()
524
525 class product_category(osv.osv):
526
527     _inherit = 'product.category'
528     _columns = {
529         'property_stock_journal': fields.property('account.journal',
530             relation='account.journal', type='many2one',
531             string='Stock Journal', view_load=True,
532             help="When doing real-time inventory valuation, this is the Accounting Journal in which entries will be automatically posted when stock moves are processed."),
533         'property_stock_account_input_categ': fields.property('account.account',
534             type='many2one', relation='account.account',
535             string='Stock Input Account', view_load=True,
536             help="When doing real-time inventory valuation, counterpart journal items for all incoming stock moves will be posted in this account, unless "
537                  "there is a specific valuation account set on the source location. This is the default value for all products in this category. It "
538                  "can also directly be set on each product"),
539         'property_stock_account_output_categ': fields.property('account.account',
540             type='many2one', relation='account.account',
541             string='Stock Output Account', view_load=True,
542             help="When doing real-time inventory valuation, counterpart journal items for all outgoing stock moves will be posted in this account, unless "
543                  "there is a specific valuation account set on the destination location. This is the default value for all products in this category. It "
544                  "can also directly be set on each product"),
545         'property_stock_valuation_account_id': fields.property('account.account',
546             type='many2one',
547             relation='account.account',
548             string="Stock Valuation Account",
549             view_load=True,
550             help="When real-time inventory valuation is enabled on a product, this account will hold the current value of the products.",),
551     }
552
553 product_category()
554
555 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: