diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 91a587fd14a..6d4c32cef2d 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -1,96 +1,173 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from openerp.osv import fields, osv -from openerp.tools.translate import _ -from openerp.tools.safe_eval import safe_eval as eval -import openerp.addons.decimal_precision as dp -from openerp.tools.float_utils import float_round -from openerp.exceptions import UserError +from odoo import api, fields, models, _ +from odoo.addons import decimal_precision as dp +from odoo.exceptions import UserError +from odoo.tools.safe_eval import safe_eval as eval +from odoo.tools.float_utils import float_round -class product_product(osv.osv): +class Product(models.Model): _inherit = "product.product" - - def _stock_move_count(self, cr, uid, ids, field_name, arg, context=None): - res = dict([(id, {'reception_count': 0, 'delivery_count': 0}) for id in ids]) - move_pool=self.pool.get('stock.move') - moves = move_pool.read_group(cr, uid, [ - ('product_id', 'in', ids), - ('location_id.usage', '!=', 'internal'), - ('location_dest_id.usage', '=', 'internal'), - ('state','in',('confirmed','assigned','pending')) - ], ['product_id'], ['product_id']) - for move in moves: - product_id = move['product_id'][0] - res[product_id]['reception_count'] = move['product_id_count'] - moves = move_pool.read_group(cr, uid, [ - ('product_id', 'in', ids), - ('location_id.usage', '=', 'internal'), - ('location_dest_id.usage', '!=', 'internal'), - ('state','in',('confirmed','assigned','pending')) - ], ['product_id'], ['product_id']) - for move in moves: - product_id = move['product_id'][0] - res[product_id]['delivery_count'] = move['product_id_count'] + + reception_count = fields.Integer('Receipt', compute='_compute_reception_count') + delivery_count = fields.Integer('Delivery', compute='_compute_delivery_count') + stock_quant_ids = fields.One2many('stock.quant', 'product_id', help='Technical: used to compute quantities.') + stock_move_ids = fields.One2many('stock.move', 'product_id', help='Technical: used to compute quantities.') + qty_available = fields.Float( + 'Quantity On Hand', compute='_compute_quantities', search='_search_qty_available', + digits_compute=dp.get_precision('Product Unit of Measure'), + help="Current quantity of products.\n" + "In a context with a single Stock Location, this includes " + "goods stored at this Location, or any of its children.\n" + "In a context with a single Warehouse, this includes " + "goods stored in the Stock Location of this Warehouse, or any " + "of its children.\n" + "stored in the Stock Location of the Warehouse of this Shop, " + "or any of its children.\n" + "Otherwise, this includes goods stored in any Stock Location " + "with 'internal' type.") + virtual_available = fields.Float( + 'Forecast Quantity', compute='_compute_quantities', search='_search_virtual_available', + digits_compute=dp.get_precision('Product Unit of Measure'), + help="Forecast quantity (computed as Quantity On Hand " + "- Outgoing + Incoming)\n" + "In a context with a single Stock Location, this includes " + "goods stored in this location, or any of its children.\n" + "In a context with a single Warehouse, this includes " + "goods stored in the Stock Location of this Warehouse, or any " + "of its children.\n" + "Otherwise, this includes goods stored in any Stock Location " + "with 'internal' type.") + incoming_qty = fields.Float( + 'Incoming', compute='_compute_quantities', search='_search_incoming_qty', + digits_compute=dp.get_precision('Product Unit of Measure'), + help="Quantity of products that are planned to arrive.\n" + "In a context with a single Stock Location, this includes " + "goods arriving to this Location, or any of its children.\n" + "In a context with a single Warehouse, this includes " + "goods arriving to the Stock Location of this Warehouse, or " + "any of its children.\n" + "Otherwise, this includes goods arriving to any Stock " + "Location with 'internal' type.") + outgoing_qty = fields.Float( + 'Outgoing', compute='_compute_quantities', search='_search_outgoing_qty', + digits_compute=dp.get_precision('Product Unit of Measure'), + help="Quantity of products that are planned to leave.\n" + "In a context with a single Stock Location, this includes " + "goods leaving this Location, or any of its children.\n" + "In a context with a single Warehouse, this includes " + "goods leaving the Stock Location of this Warehouse, or " + "any of its children.\n" + "Otherwise, this includes goods leaving any Stock " + "Location with 'internal' type.") + # TDE CLEANME: unused except in one test + orderpoint_ids = fields.One2many('stock.warehouse.orderpoint', 'product_id', 'Minimum Stock Rules') + nbr_reordering_rules = fields.Integer('Reordering Rules', compute='_compute_nbr_reordering_rules') + reordering_min_qty = fields.Float(compute='_compute_nbr_reordering_rules') + reordering_max_qty = fields.Float(compute='_compute_nbr_reordering_rules') + + @api.depends('stock_quant_ids', 'stock_move_ids') + def _compute_quantities(self): + res = self._compute_quantities_dict(self._context.get('lot_id'), self._context.get('owner_id'), self._context.get('package_id'), self._context.get('from_date'), self._context.get('to_date')) + for product in self: + product.qty_available = res[product.id]['qty_available'] + product.incoming_qty = res[product.id]['incoming_qty'] + product.outgoing_qty = res[product.id]['outgoing_qty'] + product.virtual_available = res[product.id]['virtual_available'] + + @api.multi + def _product_available(self, field_names=None, arg=False): + """ Compatibility method """ + return self._compute_quantities_dict(self._context.get('lot_id'), self._context.get('owner_id'), self._context.get('package_id'), self._context.get('from_date'), self._context.get('to_date')) + + @api.multi + def _compute_quantities_dict(self, lot_id, owner_id, package_id, from_date=False, to_date=False): + domain_quant_loc, domain_move_in_loc, domain_move_out_loc = self._get_domain_locations() + + domain_quant = [('product_id', 'in', self.ids)] + domain_quant_loc + domain_move_in = [('state', 'not in', ('done', 'cancel', 'draft')), ('product_id', 'in', self.ids)] + domain_move_in_loc + domain_move_out = [('state', 'not in', ('done', 'cancel', 'draft')), ('product_id', 'in', self.ids)] + domain_move_out_loc + if from_date: + domain_move_in += [('date', '>=', from_date)] + domain_move_out += [('date', '>=', from_date)] + if to_date: + domain_move_in += [('date', '<=', to_date)] + domain_move_out += [('date', '<=', to_date)] + if lot_id: + domain_quant += [('lot_id', '=', lot_id)] + if owner_id: + domain_quant += [('owner_id', '=', owner_id)] + domain_move_in += [('restrict_partner_id', '=', owner_id)] + domain_move_out += [('restrict_partner_id', '=', owner_id)] + if package_id: + domain_quant += [('package_id', '=', package_id)] + + Move = self.env['stock.move'] + Quant = self.env['stock.quant'] + moves_in_res = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_in, ['product_id', 'product_qty'], ['product_id'])) + moves_out_res = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_out, ['product_id', 'product_qty'], ['product_id'])) + quants_res = dict((item['product_id'][0], item['qty']) for item in Quant.read_group(domain_quant, ['product_id', 'qty'], ['product_id'])) + + res = dict() + for product in self.with_context(prefetch_fields=False): + res[product.id] = {} + res[product.id]['qty_available'] = float_round(quants_res.get(product.id, 0.0), precision_rounding=product.uom_id.rounding) + res[product.id]['incoming_qty'] = float_round(moves_in_res.get(product.id, 0.0), precision_rounding=product.uom_id.rounding) + res[product.id]['outgoing_qty'] = float_round(moves_out_res.get(product.id, 0.0), precision_rounding=product.uom_id.rounding) + res[product.id]['virtual_available'] = float_round( + res[product.id]['qty_available'] + res[product.id]['incoming_qty'] - res[product.id]['outgoing_qty'], + precision_rounding=product.uom_id.rounding) return res - def view_header_get(self, cr, user, view_id, view_type, context=None): - if context is None: - context = {} - res = super(product_product, self).view_header_get(cr, user, view_id, view_type, context) - if res: return res - if (context.get('active_id', False)) and (context.get('active_model') == 'stock.location'): - return _('Products: ')+self.pool.get('stock.location').browse(cr, user, context['active_id'], context).name - return res - - def _get_domain_locations(self, cr, uid, ids, context=None): + def _get_domain_locations(self): ''' Parses the context and returns a list of location_ids based on it. It will return all stock locations when no parameters are given Possible parameters are shop, warehouse, location, force_company, compute_child ''' - context = context or {} - - location_obj = self.pool.get('stock.location') - warehouse_obj = self.pool.get('stock.warehouse') + # TDE FIXME: clean that brol, context seems overused + Warehouse = self.env['stock.warehouse'] location_ids = [] - if context.get('location', False): - if isinstance(context['location'], (int, long)): - location_ids = [context['location']] - elif isinstance(context['location'], basestring): - domain = [('complete_name','ilike',context['location'])] - if context.get('force_company', False): - domain += [('company_id', '=', context['force_company'])] - location_ids = location_obj.search(cr, uid, domain, context=context) + if self.env.context.get('location', False): + if isinstance(self.env.context['location'], (int, long)): + location_ids = [self.env.context['location']] + elif isinstance(self.env.context['location'], basestring): + domain = [('complete_name', 'ilike', self.env.context['location'])] + if self.env.context.get('force_company', False): + domain += [('company_id', '=', self.env.context['force_company'])] + location_ids = self.env['stock.location'].search(domain).ids else: - location_ids = context['location'] + location_ids = self.env.context['location'] else: - if context.get('warehouse', False): - if isinstance(context['warehouse'], (int, long)): - wids = [context['warehouse']] - elif isinstance(context['warehouse'], basestring): - domain = [('name', 'ilike', context['warehouse'])] - if context.get('force_company', False): - domain += [('company_id', '=', context['force_company'])] - wids = warehouse_obj.search(cr, uid, domain, context=context) + if self.env.context.get('warehouse', False): + if isinstance(self.env.context['warehouse'], (int, long)): + wids = [self.env.context['warehouse']] + elif isinstance(self.env.context['warehouse'], basestring): + domain = [('name', 'ilike', self.env.context['warehouse'])] + if self.env.context.get('force_company', False): + domain += [('company_id', '=', self.env.context['force_company'])] + wids = Warehouse.search(domain).ids else: - wids = context['warehouse'] + wids = self.env.context['warehouse'] else: - wids = warehouse_obj.search(cr, uid, [], context=context) + wids = Warehouse.search([]).ids - for w in warehouse_obj.browse(cr, uid, wids, context=context): + for w in Warehouse.browse(wids): location_ids.append(w.view_location_id.id) + return self._get_domain_locations_new(location_ids, company_id=self.env.context.get('force_company', False), compute_child=self.env.context.get('compute_child', True)) - operator = context.get('compute_child', True) and 'child_of' or 'in' - domain = context.get('force_company', False) and ['&', ('company_id', '=', context['force_company'])] or [] - locations = location_obj.browse(cr, uid, location_ids, context=context) + def _get_domain_locations_new(self, location_ids, company_id=False, compute_child=True): + operator = compute_child and 'child_of' or 'in' + domain = company_id and ['&', ('company_id', '=', company_id)] or [] + locations = self.env['stock.location'].browse(location_ids) # TDE FIXME: should move the support of child_of + auto_join directly in expression # The code has been modified because having one location with parent_left being # 0 make the whole domain unusable - hierarchical_locations = [location for location in locations if location.parent_left != 0 and operator == "child_of"] - other_locations = [location for location in locations if location not in hierarchical_locations] + hierarchical_locations = locations.filtered(lambda location: location.parent_left != 0 and operator == "child_of") + other_locations = locations.filtered(lambda location: location not in hierarchical_locations) # TDE: set - set ? loc_domain = [] dest_loc_domain = [] for location in hierarchical_locations: @@ -113,258 +190,212 @@ class product_product(osv.osv): domain + ['&'] + loc_domain + ['!'] + dest_loc_domain ) - def _get_domain_dates(self, cr, uid, ids, context): - from_date = context.get('from_date', False) - to_date = context.get('to_date', False) - domain = [] - if from_date: - domain.append(('date', '>=', from_date)) - if to_date: - domain.append(('date', '<=', to_date)) - return domain + def _search_virtual_available(self, operator, value): + # TDE FIXME: should probably clean the search methods + return self._search_product_quantity(operator, value, 'virtual_available') - def _product_available(self, cr, uid, ids, field_names=None, arg=False, context=None): - context = context or {} - field_names = field_names or [] + def _search_incoming_qty(self, operator, value): + # TDE FIXME: should probably clean the search methods + return self._search_product_quantity(operator, value, 'incoming_qty') - domain_products = [('product_id', 'in', ids)] - domain_quant, domain_move_in, domain_move_out = [], [], [] - domain_quant_loc, domain_move_in_loc, domain_move_out_loc = self._get_domain_locations(cr, uid, ids, context=context) - domain_move_in += self._get_domain_dates(cr, uid, ids, context=context) + [('state', 'not in', ('done', 'cancel', 'draft'))] + domain_products - domain_move_out += self._get_domain_dates(cr, uid, ids, context=context) + [('state', 'not in', ('done', 'cancel', 'draft'))] + domain_products - domain_quant += domain_products + def _search_outgoing_qty(self, operator, value): + # TDE FIXME: should probably clean the search methods + return self._search_product_quantity(operator, value, 'outgoing_qty') - if context.get('lot_id'): - domain_quant.append(('lot_id', '=', context['lot_id'])) - if context.get('owner_id'): - domain_quant.append(('owner_id', '=', context['owner_id'])) - owner_domain = ('restrict_partner_id', '=', context['owner_id']) - domain_move_in.append(owner_domain) - domain_move_out.append(owner_domain) - if context.get('package_id'): - domain_quant.append(('package_id', '=', context['package_id'])) + def _search_product_quantity(self, operator, value, field): + # TDE FIXME: should probably clean the search methods + # to prevent sql injections + if field not in ('qty_available', 'virtual_available', 'incoming_qty', 'outgoing_qty'): + raise UserError('Invalid domain left operand') + if operator not in ('<', '>', '=', '!=', '<=', '>='): + raise UserError('Invalid domain operator') + if not isinstance(value, (float, int)): + raise UserError('Invalid domain right operand') - domain_move_in += domain_move_in_loc - domain_move_out += domain_move_out_loc - moves_in = self.pool.get('stock.move').read_group(cr, uid, domain_move_in, ['product_id', 'product_qty'], ['product_id'], context=context) - moves_out = self.pool.get('stock.move').read_group(cr, uid, domain_move_out, ['product_id', 'product_qty'], ['product_id'], context=context) + if operator == '=': + operator = '==' - domain_quant += domain_quant_loc - quants = self.pool.get('stock.quant').read_group(cr, uid, domain_quant, ['product_id', 'qty'], ['product_id'], context=context) - quants = dict(map(lambda x: (x['product_id'][0], x['qty']), quants)) + # TODO: Still optimization possible when searching virtual quantities + ids = [] + for product in self.search([]): + if eval(str(product[field]) + operator + str(value)): + ids.append(product.id) + return [('id', 'in', ids)] - moves_in = dict(map(lambda x: (x['product_id'][0], x['product_qty']), moves_in)) - moves_out = dict(map(lambda x: (x['product_id'][0], x['product_qty']), moves_out)) - res = {} - ctx = context.copy() - ctx.update({'prefetch_fields': False}) - for product in self.browse(cr, uid, ids, context=ctx): - id = product.id - qty_available = float_round(quants.get(id, 0.0), precision_rounding=product.uom_id.rounding) - incoming_qty = float_round(moves_in.get(id, 0.0), precision_rounding=product.uom_id.rounding) - outgoing_qty = float_round(moves_out.get(id, 0.0), precision_rounding=product.uom_id.rounding) - virtual_available = float_round(quants.get(id, 0.0) + moves_in.get(id, 0.0) - moves_out.get(id, 0.0), precision_rounding=product.uom_id.rounding) - res[id] = { - 'qty_available': qty_available, - 'incoming_qty': incoming_qty, - 'outgoing_qty': outgoing_qty, - 'virtual_available': virtual_available, - } - return res + def _search_qty_available(self, operator, value): + # TDE FIXME: should probably clean the search methods + if value == 0.0 and operator in ('==', '>=', '<='): + return self._search_product_quantity(operator, value, 'qty_available') + product_ids = self._search_qty_available_new(operator, value, self._context.get('lot_id'), self._context.get('owner_id'), self._context.get('package_id')) + return [('id', 'in', product_ids)] - def _search_product_quantity(self, cr, uid, obj, name, domain, context): - res = [] - for field, operator, value in domain: - #to prevent sql injections - assert field in ('qty_available', 'virtual_available', 'incoming_qty', 'outgoing_qty'), 'Invalid domain left operand' - assert operator in ('<', '>', '=', '!=', '<=', '>='), 'Invalid domain operator' - assert isinstance(value, (float, int)), 'Invalid domain right operand' + def _search_qty_available_new(self, operator, value, lot_id=False, owner_id=False, package_id=False): + # TDE FIXME: should probably clean the search methods + product_ids = set() + domain_quant = self._get_domain_locations()[0] + if lot_id: + domain_quant.append(('lot_id', '=', lot_id)) + if owner_id: + domain_quant.append(('owner_id', '=', owner_id)) + if package_id: + domain_quant.append(('package_id', '=', package_id)) + quants_groupby = self.env['stock.quant'].read_group(domain_quant, ['product_id', 'qty'], ['product_id']) + for quant in quants_groupby: + if eval('%s %s %s' % (quant['qty'], operator, value)): + product_ids.add(quant['product_id'][0]) + return list(product_ids) - if operator == '=': - operator = '==' - - ids = [] - if name == 'qty_available' and (value != 0.0 or operator not in ('==', '>=', '<=')): - res.append(('id', 'in', self._search_qty_available(cr, uid, operator, value, context))) - else: - product_ids = self.search(cr, uid, [], context=context) - if product_ids: - #TODO: Still optimization possible when searching virtual quantities - for element in self.browse(cr, uid, product_ids, context=context): - if eval(str(element[field]) + operator + str(value)): - ids.append(element.id) - res.append(('id', 'in', ids)) - return res - - def _search_qty_available(self, cr, uid, operator, value, context): - domain_quant = [] - if context.get('lot_id'): - domain_quant.append(('lot_id', '=', context['lot_id'])) - if context.get('owner_id'): - domain_quant.append(('owner_id', '=', context['owner_id'])) - if context.get('package_id'): - domain_quant.append(('package_id', '=', context['package_id'])) - domain_quant += self._get_domain_locations(cr, uid, [], context=context)[0] - quants = self.pool.get('stock.quant').read_group(cr, uid, domain_quant, ['product_id', 'qty'], ['product_id'], context=context) - quants = dict(map(lambda x: (x['product_id'][0], x['qty']), quants)) - quants = dict((k, v) for k, v in quants.iteritems() if eval(str(v) + operator + str(value))) - return(list(quants)) - - def _compute_nbr_reordering_rules(self, cr, uid, ids, field_names=None, arg=None, context=None): - res = dict.fromkeys(ids, {'nbr_reordering_rules': 0, 'reordering_min_qty': 0, 'reordering_max_qty': 0}) - product_data = self.pool['stock.warehouse.orderpoint'].read_group(cr, uid, [('product_id', 'in', ids)], ['product_id', 'product_min_qty', 'product_max_qty'], ['product_id'], context=context) - for data in product_data: + def _compute_nbr_reordering_rules(self): + read_group_res = self.env['stock.warehouse.orderpoint'].read_group( + [('product_id', 'in', self.ids)], + ['product_id', 'product_min_qty', 'product_max_qty'], + ['product_id']) + res = dict.fromkeys(self.ids, dict()) + for data in read_group_res: res[data['product_id'][0]]['nbr_reordering_rules'] = int(data['product_id_count']) res[data['product_id'][0]]['reordering_min_qty'] = data['product_min_qty'] res[data['product_id'][0]]['reordering_max_qty'] = data['product_max_qty'] + for product in self: + product.nbr_reordering_rules = res[product.id].get('nbr_reordering_rules', 0) + product.reordering_min_qty = res[product.id].get('reordering_min_qty', 0) + product.reordering_max_qty = res[product.id].get('reordering_max_qty', 0) + + @api.onchange('tracking') + def onchange_tracking(self): + products = self.filtered(lambda self: self.tracking and self.tracking != 'none') + if products: + unassigned_quants = self.env['stock.quant'].search_count([('product_id', 'in', products.ids), ('lot_id', '=', False), ('location_id.usage','=', 'internal')]) + if unassigned_quants: + return { + 'warning': { + 'title': _('Warning!'), + 'message': _("You have products in stock that have no lot number. You can assign serial numbers by doing an inventory. ")}} + + @api.model + def view_header_get(self, view_id, view_type): + res = super(Product, self).view_header_get(view_id, view_type) + if not res and self._context.get('active_id') and self._context.get('active_model') == 'stock.location': + res = '%s%s' % (_('Products: '), self.env['stock.location'].browse(self._context['active_id']).name) return res - _columns = { - 'reception_count': fields.function(_stock_move_count, string="Receipt", type='integer', multi='pickings'), - 'delivery_count': fields.function(_stock_move_count, string="Delivery", type='integer', multi='pickings'), - 'qty_available': fields.function(_product_available, multi='qty_available', - type='float', digits_compute=dp.get_precision('Product Unit of Measure'), - string='Quantity On Hand', - fnct_search=_search_product_quantity, - help="Current quantity of products.\n" - "In a context with a single Stock Location, this includes " - "goods stored at this Location, or any of its children.\n" - "In a context with a single Warehouse, this includes " - "goods stored in the Stock Location of this Warehouse, or any " - "of its children.\n" - "stored in the Stock Location of the Warehouse of this Shop, " - "or any of its children.\n" - "Otherwise, this includes goods stored in any Stock Location " - "with 'internal' type."), - 'virtual_available': fields.function(_product_available, multi='qty_available', - type='float', digits_compute=dp.get_precision('Product Unit of Measure'), - string='Forecast Quantity', - fnct_search=_search_product_quantity, - help="Forecast quantity (computed as Quantity On Hand " - "- Outgoing + Incoming)\n" - "In a context with a single Stock Location, this includes " - "goods stored in this location, or any of its children.\n" - "In a context with a single Warehouse, this includes " - "goods stored in the Stock Location of this Warehouse, or any " - "of its children.\n" - "Otherwise, this includes goods stored in any Stock Location " - "with 'internal' type."), - 'incoming_qty': fields.function(_product_available, multi='qty_available', - type='float', digits_compute=dp.get_precision('Product Unit of Measure'), - string='Incoming', - fnct_search=_search_product_quantity, - help="Quantity of products that are planned to arrive.\n" - "In a context with a single Stock Location, this includes " - "goods arriving to this Location, or any of its children.\n" - "In a context with a single Warehouse, this includes " - "goods arriving to the Stock Location of this Warehouse, or " - "any of its children.\n" - "Otherwise, this includes goods arriving to any Stock " - "Location with 'internal' type."), - 'outgoing_qty': fields.function(_product_available, multi='qty_available', - type='float', digits_compute=dp.get_precision('Product Unit of Measure'), - string='Outgoing', - fnct_search=_search_product_quantity, - help="Quantity of products that are planned to leave.\n" - "In a context with a single Stock Location, this includes " - "goods leaving this Location, or any of its children.\n" - "In a context with a single Warehouse, this includes " - "goods leaving the Stock Location of this Warehouse, or " - "any of its children.\n" - "Otherwise, this includes goods leaving any Stock " - "Location with 'internal' type."), - 'orderpoint_ids': fields.one2many('stock.warehouse.orderpoint', 'product_id', 'Minimum Stock Rules'), - 'nbr_reordering_rules': fields.function(_compute_nbr_reordering_rules, string='Reordering Rules', type='integer', multi=True), - 'reordering_min_qty': fields.function(_compute_nbr_reordering_rules, type='float', multi=True), - 'reordering_max_qty': fields.function(_compute_nbr_reordering_rules, type='float', multi=True), - } - - def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False): - res = super(product_product, self).fields_view_get( - cr, uid, view_id=view_id, view_type=view_type, context=context, - toolbar=toolbar, submenu=submenu) - if context is None: - context = {} - if context.get('location') and isinstance(context['location'], int): - location_info = self.pool.get('stock.location').browse(cr, uid, context['location']) - fields=res.get('fields',{}) + @api.model + def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + res = super(Product, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + if self._context.get('location') and isinstance(self._context['location'], (int, long)): + location = self.env['stock.location'].browse(self._context['location']) + fields = res.get('fields') if fields: - if location_info.usage == 'supplier': + if location.usage == 'supplier': if fields.get('virtual_available'): res['fields']['virtual_available']['string'] = _('Future Receipts') if fields.get('qty_available'): res['fields']['qty_available']['string'] = _('Received Qty') - - if location_info.usage == 'internal': + elif location.usage == 'internal': if fields.get('virtual_available'): res['fields']['virtual_available']['string'] = _('Forecasted Quantity') - - if location_info.usage == 'customer': + elif location.usage == 'customer': if fields.get('virtual_available'): res['fields']['virtual_available']['string'] = _('Future Deliveries') if fields.get('qty_available'): res['fields']['qty_available']['string'] = _('Delivered Qty') - - if location_info.usage == 'inventory': + elif location.usage == 'inventory': if fields.get('virtual_available'): res['fields']['virtual_available']['string'] = _('Future P&L') if fields.get('qty_available'): res['fields']['qty_available']['string'] = _('P&L Qty') - - if location_info.usage == 'procurement': + elif location.usage == 'procurement': if fields.get('virtual_available'): res['fields']['virtual_available']['string'] = _('Future Qty') if fields.get('qty_available'): res['fields']['qty_available']['string'] = _('Unplanned Qty') - - if location_info.usage == 'production': + elif location.usage == 'production': if fields.get('virtual_available'): res['fields']['virtual_available']['string'] = _('Future Productions') if fields.get('qty_available'): res['fields']['qty_available']['string'] = _('Produced Qty') return res - - def action_view_routes(self, cr, uid, ids, context=None): - template_obj = self.pool.get("product.template") - templ_ids = list(set([x.product_tmpl_id.id for x in self.browse(cr, uid, ids, context=context)])) - return template_obj.action_view_routes(cr, uid, templ_ids, context=context) - - def onchange_tracking(self, cr, uid, ids, tracking, context=None): - if not tracking or tracking == 'none': - return {} - unassigned_quants = self.pool['stock.quant'].search_count(cr, uid, [('product_id','in', ids), ('lot_id','=', False), ('location_id.usage','=', 'internal')], context=context) - if unassigned_quants: - return {'warning' : { - 'title': _('Warning!'), - 'message' : _("You have products in stock that have no lot number. You can assign serial numbers by doing an inventory. ") - }} - return {} + @api.multi + def action_view_routes(self): + return self.mapped('product_tmpl_id').action_view_routes() -class product_template(osv.osv): - _name = 'product.template' +class ProductTemplate(models.Model): _inherit = 'product.template' - - def _product_available(self, cr, uid, ids, name, arg, context=None): - prod_available = {} - product_ids = self.browse(cr, uid, ids, context=context) - var_ids = [] - for product in product_ids: - var_ids += [p.id for p in product.product_variant_ids] - variant_available= self.pool['product.product']._product_available(cr, uid, var_ids, context=context) - for product in product_ids: + property_stock_procurement = fields.Many2one( + 'stock.location', "Procurement Location", + company_dependent=True, domain=[('usage', 'like', 'procurement')], + help="This stock location will be used, instead of the default one, as the source location for stock moves generated by procurements.") + property_stock_production = fields.Many2one( + 'stock.location', "Production Location", + company_dependent=True, domain=[('usage', 'like', 'production')], + help="This stock location will be used, instead of the default one, as the source location for stock moves generated by manufacturing orders.") + property_stock_inventory = fields.Many2one( + 'stock.location', "Inventory Location", + company_dependent=True, domain=[('usage', 'like', 'inventory')], + 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.") + sale_delay = fields.Float( + 'Customer Lead Time', default=0, + 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.") + tracking = fields.Selection([ + ('serial', 'By Unique Serial Number'), + ('lot', 'By Lots'), + ('none', 'No Tracking')], string="Tracking", default='none', required=True) + description_picking = fields.Text('Description on Picking', translate=True) + qty_available = fields.Float( + 'Quantity On Hand', compute='_compute_quantities', search='_search_quantities', + digits_compute=dp.get_precision('Product Unit of Measure')) + virtual_available = fields.Float( + 'Forecasted Quantity', compute='_compute_quantities', search='_search_quantities', + digits_compute=dp.get_precision('Product Unit of Measure')) + incoming_qty = fields.Float( + 'Incoming', compute='_compute_quantities', search='_search_quantities', + digits_compute=dp.get_precision('Product Unit of Measure')) + outgoing_qty = fields.Float( + 'Outgoing', compute='_compute_quantities', search='_search_quantities', + digits_compute=dp.get_precision('Product Unit of Measure')) + location_id = fields.Many2one('stock.location', 'Location') + warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse') + route_ids = fields.Many2many( + 'stock.location.route', 'stock_route_product', 'product_id', 'route_id', 'Routes', + domain="[('product_selectable', '=', True)]", + help="Depending on the modules installed, this will allow you to define the route of the product: whether it will be bought, manufactured, MTO/MTS,...") + nbr_reordering_rules = fields.Integer('Reordering Rules', compute='_compute_nbr_reordering_rules') + # TDE FIXME: really used ? + reordering_min_qty = fields.Float(compute='_compute_nbr_reordering_rules') + reordering_max_qty = fields.Float(compute='_compute_nbr_reordering_rules') + # TDE FIXME: seems only visible in a view - remove me ? + route_from_categ_ids = fields.Many2many( + relation="stock.location.route", string="Category Routes", + related='categ_id.total_route_ids') + + def _compute_quantities(self): + res = self._compute_quantities_dict() + for template in self: + template.qty_available = res[template.id]['qty_available'] + template.virtual_available = res[template.id]['virtual_available'] + template.incoming_qty = res[template.id]['incoming_qty'] + template.outgoing_qty = res[template.id]['outgoing_qty'] + + def _product_available(self, name, arg): + return self._compute_quantities_dict() + + def _compute_quantities_dict(self): + # TDE FIXME: why not using directly the function fields ? + variants_available = self.mapped('product_variant_ids')._product_available() + prod_available = {} + for template in self: qty_available = 0 virtual_available = 0 incoming_qty = 0 outgoing_qty = 0 - for p in product.product_variant_ids: - qty_available += variant_available[p.id]["qty_available"] - virtual_available += variant_available[p.id]["virtual_available"] - incoming_qty += variant_available[p.id]["incoming_qty"] - outgoing_qty += variant_available[p.id]["outgoing_qty"] - prod_available[product.id] = { + for p in template.product_variant_ids: + qty_available += variants_available[p.id]["qty_available"] + virtual_available += variants_available[p.id]["virtual_available"] + incoming_qty += variants_available[p.id]["incoming_qty"] + outgoing_qty += variants_available[p.id]["outgoing_qty"] + prod_available[template.id] = { "qty_available": qty_available, "virtual_available": virtual_available, "incoming_qty": incoming_qty, @@ -372,164 +403,101 @@ class product_template(osv.osv): } return prod_available - def _search_product_quantity(self, cr, uid, obj, name, domain, context): - prod = self.pool.get("product.product") - product_variant_ids = prod.search(cr, uid, domain, context=context) - return [('product_variant_ids', 'in', product_variant_ids)] + def _search_quantities(self, operator, value): + # TDE FIXME: does this work anyway ? + domain = [] # TDE ADDED + product_variant_ids = self.env['product.product'].search(domain) + return [('product_variant_ids', 'in', product_variant_ids.ids)] - def _compute_nbr_reordering_rules(self, cr, uid, ids, field_names=None, arg=None, context=None): - res = {k: {'nbr_reordering_rules': 0, 'reordering_min_qty': 0, 'reordering_max_qty': 0} for k in ids} - product_data = self.pool['stock.warehouse.orderpoint'].read_group(cr, uid, [('product_id.product_tmpl_id', 'in', ids)], ['product_id', 'product_min_qty', 'product_max_qty'], ['product_id'], context=context) + def _compute_nbr_reordering_rules(self): + res = {k: {'nbr_reordering_rules': 0, 'reordering_min_qty': 0, 'reordering_max_qty': 0} for k in self.ids} + product_data = self.env['stock.warehouse.orderpoint'].read_group([('product_id.product_tmpl_id', 'in', self.ids)], ['product_id', 'product_min_qty', 'product_max_qty'], ['product_id']) for data in product_data: - p = self.pool['product.product'].browse(cr, uid, [data['product_id'][0]], context=context) - product_tmpl_id = p.product_tmpl_id.id + product = self.env['product.product'].browse([data['product_id'][0]]) + product_tmpl_id = product.product_tmpl_id.id res[product_tmpl_id]['nbr_reordering_rules'] += int(data['product_id_count']) res[product_tmpl_id]['reordering_min_qty'] = data['product_min_qty'] res[product_tmpl_id]['reordering_max_qty'] = data['product_max_qty'] - return res + for template in self: + self.nbr_reordering_rules = res[template.id]['nbr_reordering_rules'] + self.reordering_min_qty = res[template.id]['reordering_min_qty'] + self.reordering_max_qty = res[template.id]['reordering_max_qty'] - def _get_product_template_type(self, cr, uid, context=None): - res = super(product_template, self)._get_product_template_type(cr, uid, context=context) + @api.model + def _get_product_template_type(self): + res = super(ProductTemplate, self)._get_product_template_type() if 'product' not in [item[0] for item in res]: res.append(('product', _('Stockable Product'))) return res - _columns = { - 'property_stock_procurement': fields.property( - type='many2one', - relation='stock.location', - string="Procurement Location", - domain=[('usage','like','procurement')], - help="This stock location will be used, instead of the default one, as the source location for stock moves generated by procurements."), - 'property_stock_production': fields.property( - type='many2one', - relation='stock.location', - string="Production Location", - domain=[('usage','like','production')], - help="This stock location will be used, instead of the default one, as the source location for stock moves generated by manufacturing orders."), - 'property_stock_inventory': fields.property( - type='many2one', - relation='stock.location', - string="Inventory Location", - domain=[('usage','like','inventory')], - 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."), - '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."), - 'tracking': fields.selection(selection=[('serial', 'By Unique Serial Number'), ('lot', 'By Lots'), ('none', 'No Tracking')], string="Tracking", required=True), - 'description_picking': fields.text('Description on Picking', translate=True), - # sum of product variant qty - # 'reception_count': fields.function(_product_available, multi='qty_available', - # fnct_search=_search_product_quantity, type='float', string='Quantity On Hand'), - # 'delivery_count': fields.function(_product_available, multi='qty_available', - # fnct_search=_search_product_quantity, type='float', string='Quantity On Hand'), - 'qty_available': fields.function(_product_available, multi='qty_available', digits_compute=dp.get_precision('Product Unit of Measure'), - fnct_search=_search_product_quantity, type='float', string='Quantity On Hand'), - 'virtual_available': fields.function(_product_available, multi='qty_available', digits_compute=dp.get_precision('Product Unit of Measure'), - fnct_search=_search_product_quantity, type='float', string='Forecasted Quantity'), - 'incoming_qty': fields.function(_product_available, multi='qty_available', digits_compute=dp.get_precision('Product Unit of Measure'), - fnct_search=_search_product_quantity, type='float', string='Incoming'), - 'outgoing_qty': fields.function(_product_available, multi='qty_available', digits_compute=dp.get_precision('Product Unit of Measure'), - fnct_search=_search_product_quantity, type='float', string='Outgoing'), - 'location_id': fields.dummy(string='Location', relation='stock.location', type='many2one'), - 'warehouse_id': fields.dummy(string='Warehouse', relation='stock.warehouse', type='many2one'), - 'route_ids': fields.many2many('stock.location.route', 'stock_route_product', 'product_id', 'route_id', 'Routes', domain="[('product_selectable', '=', True)]", - help="Depending on the modules installed, this will allow you to define the route of the product: whether it will be bought, manufactured, MTO/MTS,..."), - 'nbr_reordering_rules': fields.function(_compute_nbr_reordering_rules, string='Reordering Rules', type='integer', multi=True), - 'reordering_min_qty': fields.function(_compute_nbr_reordering_rules, type='float', multi=True), - 'reordering_max_qty': fields.function(_compute_nbr_reordering_rules, type='float', multi=True), - 'route_from_categ_ids': fields.related('categ_id', 'total_route_ids', type="many2many", relation="stock.location.route", string="Category Routes"), - } + @api.onchange('tracking') + def onchange_tracking(self): + return self.mapped('product_variant_ids').onchange_tracking() - _defaults = { - 'sale_delay': 0, - 'tracking': 'none', - } - - def action_view_routes(self, cr, uid, ids, context=None): - route_obj = self.pool.get("stock.location.route") - act_obj = self.pool.get('ir.actions.act_window') - mod_obj = self.pool.get('ir.model.data') - product_route_ids = set() - for product in self.browse(cr, uid, ids, context=context): - product_route_ids |= set([r.id for r in product.route_ids]) - product_route_ids |= set([r.id for r in product.categ_id.total_route_ids]) - route_ids = route_obj.search(cr, uid, ['|', ('id', 'in', list(product_route_ids)), ('warehouse_selectable', '=', True)], context=context) - result = mod_obj.xmlid_to_res_id(cr, uid, 'stock.action_routes_form', raise_if_not_found=True) - result = act_obj.read(cr, uid, [result], context=context)[0] - result['domain'] = "[('id','in',[" + ','.join(map(str, route_ids)) + "])]" - return result - - def onchange_tracking(self, cr, uid, ids, tracking, context=None): - if not tracking: - return {} - product_product = self.pool['product.product'] - variant_ids = product_product.search(cr, uid, [('product_tmpl_id', 'in', ids)], context=context) - return product_product.onchange_tracking(cr, uid, variant_ids, tracking, context=context) - - def _get_products(self, cr, uid, ids, context=None): - products = [] - for prodtmpl in self.browse(cr, uid, ids, context=None): - products += [x.id for x in prodtmpl.product_variant_ids] - return products - - def _get_act_window_dict(self, cr, uid, name, context=None): - mod_obj = self.pool.get('ir.model.data') - act_obj = self.pool.get('ir.actions.act_window') - result = mod_obj.xmlid_to_res_id(cr, uid, name, raise_if_not_found=True) - result = act_obj.read(cr, uid, [result], context=context)[0] - return result - - def action_open_quants(self, cr, uid, ids, context=None): - products = self._get_products(cr, uid, ids, context=context) - result = self._get_act_window_dict(cr, uid, 'stock.product_open_quants', context=context) - result['domain'] = "[('product_id','in',[" + ','.join(map(str, products)) + "])]" - result['context'] = "{'search_default_locationgroup': 1, 'search_default_internal_loc': 1}" - return result - - def action_view_orderpoints(self, cr, uid, ids, context=None): - products = self._get_products(cr, uid, ids, context=context) - result = self._get_act_window_dict(cr, uid, 'stock.product_open_orderpoint', context=context) - if len(ids) == 1 and len(products) == 1: - result['context'] = "{'default_product_id': " + str(products[0]) + ", 'search_default_product_id': " + str(products[0]) + "}" - else: - result['domain'] = "[('product_id','in',[" + ','.join(map(str, products)) + "])]" - result['context'] = "{}" - return result - - def action_view_stock_moves(self, cr, uid, ids, context=None): - products = self._get_products(cr, uid, ids, context=context) - result = self._get_act_window_dict(cr, uid, 'stock.act_product_stock_move_open', context=context) - if products: - result['context'] = "{'default_product_id': %d}" % products[0] - result['domain'] = "[('product_id.product_tmpl_id','in',[" + ','.join(map(str,ids)) + "])]" - return result - - def write(self, cr, uid, ids, vals, context=None): + @api.multi + def write(self, vals): if 'uom_id' in vals: - new_uom = self.pool.get('product.uom').browse(cr, uid, vals['uom_id'], context=context) - for product in self.browse(cr, uid, ids, context=context): - old_uom = product.uom_id - if old_uom != new_uom: - if self.pool.get('stock.move').search(cr, uid, [('product_id', 'in', [x.id for x in product.product_variant_ids]), ('state', '=', 'done')], limit=1, context=context): - raise UserError(_("You can not change the unit of measure of a product that has already been used in a done stock move. If you need to change the unit of measure, you may deactivate this product.")) - return super(product_template, self).write(cr, uid, ids, vals, context=context) + new_uom = self.env['product.uom'].browse(vals['uom_id']) + updated = self.filtered(lambda template: template.uom_id != new_uom) + done_moves = self.env['stock.move'].search([('product_id', 'in', updated.mapped('product_variant_ids').ids)], limit=1) + if done_moves: + raise UserError(_("You can not change the unit of measure of a product that has already been used in a done stock move. If you need to change the unit of measure, you may deactivate this product.")) + return super(ProductTemplate, self).write(vals) + + @api.multi + def action_view_routes(self): + routes = self.mapped('route_ids') | self.mapped('categ_id').mapped('total_route_ids') | self.env['stock.location.route'].search([('warehouse_selectable', '=', True)]) + action = self.env.ref('stock.action_routes_form').read()[0] + action['domain'] = [('id', 'in', routes.ids)] + return action + + @api.multi + def action_open_quants(self): + products = self.mapped('product_variant_ids') + action = self.env.ref('stock.product_open_quants').read()[0] + action['domain'] = [('product_id', 'in', products.ids)] + action['context'] = {'search_default_locationgroup': 1, 'search_default_internal_loc': 1} + return action + + @api.multi + def action_view_orderpoints(self): + products = self.mapped('product_variant_ids') + action = self.env.ref('stock.product_open_orderpoint').read()[0] + if products and len(products) == 1: + action['context'] = {'default_product_id': products.ids[0], 'search_default_product_id': products.ids[0]} + else: + action['domain'] = [('product_id', 'in', products.ids)] + action['context'] = {} + return action + + @api.multi + def action_view_stock_moves(self): + products = self.mapped('product_variant_ids') + action = self.env.ref('stock.act_product_stock_move_open').read()[0] + if self: + action['context'] = {'default_product_id': products.ids[0]} + action['domain'] = [('product_id.product_tmpl_id', 'in', products.ids)] + return action -class product_category(osv.osv): +class ProductCategory(models.Model): _inherit = 'product.category' - def calculate_total_routes(self, cr, uid, ids, name, args, context=None): - res = {} - for categ in self.browse(cr, uid, ids, context=context): - categ2 = categ - routes = [x.id for x in categ.route_ids] - while categ2.parent_id: - categ2 = categ2.parent_id - routes += [x.id for x in categ2.route_ids] - res[categ.id] = routes - return res + route_ids = fields.Many2many( + 'stock.location.route', 'stock_location_route_categ', 'categ_id', 'route_id', 'Routes', + domain="[('product_categ_selectable', '=', True)]") + removal_strategy_id = fields.Many2one( + 'product.removal', 'Force Removal Strategy', + help="Set a specific removal strategy that will be used regardless of the source location for this product category") + total_route_ids = fields.Many2many( + 'stock.location.route', string='Total routes', compute='_compute_total_route_ids', + readonly=True) - _columns = { - 'route_ids': fields.many2many('stock.location.route', 'stock_location_route_categ', 'categ_id', 'route_id', 'Routes', domain="[('product_categ_selectable', '=', True)]"), - 'removal_strategy_id': fields.many2one('product.removal', 'Force Removal Strategy', help="Set a specific removal strategy that will be used regardless of the source location for this product category"), - 'total_route_ids': fields.function(calculate_total_routes, relation='stock.location.route', type='many2many', string='Total routes', readonly=True), - } + @api.one + def _compute_total_route_ids(self): + category = self + routes = self.route_ids + while category.parent_id: + category = category.parent_id + routes |= category.route_ids + self.total_route_ids = routes diff --git a/addons/stock/models/product_strategy.py b/addons/stock/models/product_strategy.py index 3b8d19e4d40..f4987babd43 100644 --- a/addons/stock/models/product_strategy.py +++ b/addons/stock/models/product_strategy.py @@ -1,53 +1,50 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from openerp.osv import fields, osv +from odoo import fields, models -class product_removal_strategy(osv.osv): +class RemovalStrategy(models.Model): _name = 'product.removal' _description = 'Removal Strategy' - _columns = { - 'name': fields.char('Name', required=True), - 'method': fields.char("Method", required=True, help="FIFO, LIFO..."), - } + name = fields.Char('Name', required=True) + method = fields.Char("Method", required=True, help="FIFO, LIFO...") -class product_putaway_strategy(osv.osv): +class PutAwayStrategy(models.Model): _name = 'product.putaway' _description = 'Put Away Strategy' - def _get_putaway_options(self, cr, uid, context=None): + name = fields.Char('Name', required=True) + method = fields.Selection('_get_putaway_options', "Method", default='fixed', required=True) + fixed_location_ids = fields.One2many( + 'stock.fixed.putaway.strat', 'putaway_id', 'Fixed Locations Per Product Category', copy=True, + help="When the method is fixed, this location will be used to store the products") + + def _get_putaway_options(self): return [('fixed', 'Fixed Location')] - _columns = { - 'name': fields.char('Name', required=True), - 'method': fields.selection(_get_putaway_options, "Method", required=True), - 'fixed_location_ids': fields.one2many('stock.fixed.putaway.strat', 'putaway_id', 'Fixed Locations Per Product Category', help="When the method is fixed, this location will be used to store the products", copy=True), - } + def _putaway_apply_fixed(self, product): + for strat in self.fixed_location_ids: + categ = product.categ_id + while categ: + if strat.category_id.id == categ.id: + return strat.fixed_location_id.id + categ = categ.parent_id + return self.env['stock.location'] - _defaults = { - 'method': 'fixed', - } - - def putaway_apply(self, cr, uid, ids, product, context=None): - putaway_strat = self.browse(cr, uid, ids[0], context=context) - if putaway_strat.method == 'fixed': - for strat in putaway_strat.fixed_location_ids: - categ = product.categ_id - while categ: - if strat.category_id.id == categ.id: - return strat.fixed_location_id.id - categ = categ.parent_id + def putaway_apply(self, product): + if hasattr(self, '_putaway_apply_%s' % (self.method)): + return getattr(self, '_putaway_apply_%s' % (self.method)) + return self.env['stock.location'] -class fixed_putaway_strat(osv.osv): +class FixedPutAwayStrategy(models.Model): _name = 'stock.fixed.putaway.strat' _order = 'sequence' - _columns = { - 'putaway_id': fields.many2one('product.putaway', 'Put Away Method', required=True), - 'category_id': fields.many2one('product.category', 'Product Category', required=True), - 'fixed_location_id': fields.many2one('stock.location', 'Location', required=True), - 'sequence': fields.integer('Priority', help="Give to the more specialized category, a higher priority to have them in top of the list."), - } \ No newline at end of file + + putaway_id = fields.Many2one('product.putaway', 'Put Away Method', required=True) + category_id = fields.Many2one('product.category', 'Product Category', required=True) + fixed_location_id = fields.Many2one('stock.location', 'Location', required=True) + sequence = fields.Integer('Priority', help="Give to the more specialized category, a higher priority to have them in top of the list.") diff --git a/addons/stock/views/product_views.xml b/addons/stock/views/product_views.xml index fc668c0d2e6..8641f9e0909 100644 --- a/addons/stock/views/product_views.xml +++ b/addons/stock/views/product_views.xml @@ -124,7 +124,7 @@
- +