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