From 227710041f43456b255315a2b197e412bf640015 Mon Sep 17 00:00:00 2001 From: Fatemi Lokhandwala Date: Fri, 6 Jul 2018 18:28:06 +0530 Subject: [PATCH] [ADD]:Added Upstream Patch for stock --- addons/stock/models/procurement.py | 2 +- addons/stock/models/product.py | 67 ++- addons/stock/models/res_company.py | 10 +- addons/stock/models/stock_inventory.py | 2 +- addons/stock/models/stock_location.py | 15 + addons/stock/models/stock_move.py | 164 ++++-- addons/stock/models/stock_move_line.py | 14 +- addons/stock/models/stock_picking.py | 12 +- addons/stock/models/stock_production_lot.py | 12 + addons/stock/models/stock_quant.py | 69 ++- addons/stock/models/stock_traceability.py | 68 ++- addons/stock/models/stock_warehouse.py | 29 +- .../stock/report/report_location_barcode.xml | 4 +- addons/stock/report/report_stock_forecast.py | 4 +- addons/stock/report/report_stock_forecast.xml | 4 +- .../report/report_stockpicking_operations.xml | 6 +- addons/stock/tests/__init__.py | 1 + addons/stock/tests/test_move.py | 519 ++++++++++++++++-- addons/stock/tests/test_move2.py | 143 +++++ addons/stock/tests/test_quant.py | 47 +- addons/stock/tests/test_robustness.py | 147 +++++ addons/stock/tests/test_warehouse.py | 209 +++++++ addons/stock/views/procurement_views.xml | 1 + addons/stock/views/stock_location_views.xml | 3 +- addons/stock/views/stock_move_line_views.xml | 8 +- addons/stock/views/stock_move_views.xml | 8 +- 26 files changed, 1371 insertions(+), 197 deletions(-) create mode 100644 addons/stock/tests/test_robustness.py diff --git a/addons/stock/models/procurement.py b/addons/stock/models/procurement.py index 9e77a690..1b4b9c8c 100644 --- a/addons/stock/models/procurement.py +++ b/addons/stock/models/procurement.py @@ -77,7 +77,7 @@ class ProcurementRule(models.Model): data = self._get_stock_move_values(product_id, product_qty, product_uom, location_id, name, origin, values, group_id) # Since action_confirm launch following procurement_group we should activate it. - move = self.env['stock.move'].sudo().create(data) + move = self.env['stock.move'].sudo().with_context(force_company=data.get('company_id', False)).create(data) move._action_confirm() return True diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index bcdb4fa1..31b4ab5a 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -98,13 +98,13 @@ class Product(models.Model): domain_move_in = [('product_id', 'in', self.ids)] + domain_move_in_loc domain_move_out = [('product_id', 'in', self.ids)] + domain_move_out_loc - if lot_id: + if lot_id is not None: domain_quant += [('lot_id', '=', lot_id)] - if owner_id: + if owner_id is not None: 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: + if package_id is not None: domain_quant += [('package_id', '=', package_id)] if dates_in_the_past: domain_move_in_done = list(domain_move_in) @@ -221,6 +221,19 @@ class Product(models.Model): domain + loc_domain + ['!'] + dest_loc_domain if dest_loc_domain else domain + loc_domain ) + def _search_qty_available(self, operator, value): + # In the very specific case we want to retrieve products with stock available, we only need + # to use the quants, not the stock moves. Therefore, we bypass the usual + # '_search_product_quantity' method and call '_search_qty_available_new' instead. This + # allows better performances. + if value == 0.0 and operator == '>' and not ({'from_date', 'to_date'} & set(self.env.context.keys())): + product_ids = self._search_qty_available_new( + operator, value, self.env.context.get('lot_id'), self.env.context.get('owner_id'), + self.env.context.get('package_id') + ) + return [('id', 'in', product_ids)] + return self._search_product_quantity(operator, value, 'qty_available') + def _search_virtual_available(self, operator, value): # TDE FIXME: should probably clean the search methods return self._search_product_quantity(operator, value, 'virtual_available') @@ -245,24 +258,13 @@ class Product(models.Model): # TODO: Still optimization possible when searching virtual quantities ids = [] - for product in self.search([]): + for product in self.with_context(prefetch_fields=False).search([]): if OPERATORS[operator](product[field], value): ids.append(product.id) return [('id', 'in', ids)] - 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')) - if (value > 0 and operator in ('<=', '<')) or (value < 0 and operator in ('>=', '>')): - # include also unavailable products - domain = self._search_product_quantity(operator, value, 'qty_available') - product_ids += domain[0][2] - return [('id', 'in', product_ids)] - 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 + ''' Optimized method which doesn't search on stock.moves, only on stock.quants. ''' product_ids = set() domain_quant = self._get_domain_locations()[0] if lot_id: @@ -410,8 +412,11 @@ class ProductTemplate(models.Model): outgoing_qty = fields.Float( 'Outgoing', compute='_compute_quantities', search='_search_outgoing_qty', digits=dp.get_precision('Product Unit of Measure')) - location_id = fields.Many2one('stock.location', 'Location') - warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse') + # The goal of these fields is not to be able to search a location_id/warehouse_id but + # to properly make these fields "dummy": only used to put some keys in context from + # the search view in order to influence computed field + location_id = fields.Many2one('stock.location', 'Location', store=False, search=lambda operator, operand, vals: []) + warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse', store=False, search=lambda operator, operand, vals: []) route_ids = fields.Many2many( 'stock.location.route', 'stock_route_product', 'product_id', 'route_id', 'Routes', domain=[('product_selectable', '=', True)], @@ -503,7 +508,7 @@ class ProductTemplate(models.Model): if 'uom_id' in vals: 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) + done_moves = self.env['stock.move'].search([('product_id', 'in', updated.with_context(active_test=False).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.")) if 'type' in vals and vals['type'] != 'product' and sum(self.mapped('nbr_reordering_rules')) != 0: @@ -578,3 +583,27 @@ class ProductCategory(models.Model): category = category.parent_id routes |= category.route_ids self.total_route_ids = routes + + +class ProductUoM(models.Model): + _inherit = 'product.uom' + + def write(self, values): + # Users can not update the factor if open stock moves are based on it + if 'factor' in values or 'factor_inv' in values or 'category_id' in values: + changed = self.filtered( + lambda u: any(u[f] != values[f] if f in values else False + for f in {'factor', 'factor_inv', 'category_id'})) + if changed: + stock_move_lines = self.env['stock.move.line'].search_count([ + ('product_uom_id.category_id', 'in', changed.mapped('category_id.id')), + ('state', '!=', 'cancel'), + ]) + + if stock_move_lines: + raise UserError(_( + "You cannot change the ratio of this unit of mesure as some" + " products with this UoM have already been moved or are " + "currently reserved." + )) + return super(ProductUoM, self).write(values) diff --git a/addons/stock/models/res_company.py b/addons/stock/models/res_company.py index 30aef791..de1db580 100644 --- a/addons/stock/models/res_company.py +++ b/addons/stock/models/res_company.py @@ -28,12 +28,18 @@ class Company(models.Model): location.sudo().write({'company_id': self.id}) self.write({'internal_transit_location_id': location.id}) + warehouses = self.env['stock.warehouse'].search([('partner_id', '=', self.partner_id.id)]) + warehouses.mapped('partner_id').with_context(force_company=self.id).write({ + 'property_stock_customer': location.id, + 'property_stock_supplier': location.id, + }) + @api.model def create(self, vals): company = super(Company, self).create(vals) - # multi-company rules prevents creating warehouse and sub-locations + company.create_transit_location() + # mutli-company rules prevents creating warehouse and sub-locations self.env['stock.warehouse'].check_access_rights('create') self.env['stock.warehouse'].sudo().create({'name': company.name, 'code': company.name[:5], 'company_id': company.id, 'partner_id': company.partner_id.id}) - company.create_transit_location() return company diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py index e5e5e08f..a817ebce 100644 --- a/addons/stock/models/stock_inventory.py +++ b/addons/stock/models/stock_inventory.py @@ -339,7 +339,7 @@ class InventoryLine(models.Model): # TDE FIXME: necessary ? -> replace by location_id prodlot_name = fields.Char( 'Serial Number Name', - related='prod_lot_id.name', store=True) + related='prod_lot_id.name', store=True, readonly=True) company_id = fields.Many2one( 'res.company', 'Company', related='inventory_id.company_id', index=True, readonly=True, store=True) diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py index b569bd56..6d45915a 100644 --- a/addons/stock/models/stock_location.py +++ b/addons/stock/models/stock_location.py @@ -91,6 +91,21 @@ class Location(models.Model): if 'usage' in values and values['usage'] == 'view': if self.mapped('quant_ids'): raise UserError(_("This location's usage cannot be changed to view as it contains products.")) + if 'usage' in values or 'scrap_location' in values: + + modified_locations = self.filtered( + lambda l: any(l[f] != values[f] if f in values else False + for f in {'usage', 'scrap_location'})) + reserved_quantities = self.env['stock.move.line'].search_count([ + ('location_id', 'in', modified_locations.ids), + ('product_qty', '>', 0), + ]) + if reserved_quantities: + raise UserError(_( + "You cannot change the location type or its use as a scrap" + " location as there are products reserved in this location." + " Please unreserve the products first." + )) return super(Location, self).write(values) @api.multi diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 3b05bd53..f39de474 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -275,14 +275,23 @@ class StockMove(models.Model): """ This will return the move lines to consider when applying _quantity_done_compute on a stock.move. In some context, such as MRP, it is necessary to compute quantity_done on filtered sock.move.line.""" self.ensure_one() - return self.move_line_ids + return self.move_line_ids or self.move_line_nosuggest_ids - @api.depends('move_line_ids.qty_done', 'move_line_ids.product_uom_id') + @api.depends('move_line_ids.qty_done', 'move_line_ids.product_uom_id', 'move_line_nosuggest_ids.qty_done') def _quantity_done_compute(self): + """ This field represents the sum of the move lines `qty_done`. It allows the user to know + if there is still work to do. + + We take care of rounding this value at the general decimal precision and not the rounding + of the move's UOM to make sure this value is really close to the real sum, because this + field will be used in `_action_done` in order to know if the move will need a backorder or + an extra move. + """ for move in self: + quantity_done = 0 for move_line in move._get_move_lines(): - # Transform the move_line quantity_done into the move uom. - move.quantity_done += move_line.product_uom_id._compute_quantity(move_line.qty_done, move.product_uom) + quantity_done += move_line.product_uom_id._compute_quantity(move_line.qty_done, move.product_uom, round=False) + move.quantity_done = quantity_done def _quantity_done_set(self): quantity_done = self[0].quantity_done # any call to create will invalidate `move.quantity_done` @@ -459,10 +468,10 @@ class StockMove(models.Model): if propagated_date_field: current_date = datetime.strptime(move.date_expected, DEFAULT_SERVER_DATETIME_FORMAT) new_date = datetime.strptime(vals.get(propagated_date_field), DEFAULT_SERVER_DATETIME_FORMAT) - delta = relativedelta.relativedelta(new_date, current_date) - if abs(delta.days) >= move.company_id.propagation_minimum_delta: + delta_days = (new_date - current_date).total_seconds() / 86400 + if abs(delta_days) >= move.company_id.propagation_minimum_delta: old_move_date = datetime.strptime(move.move_dest_ids[0].date_expected, DEFAULT_SERVER_DATETIME_FORMAT) - new_move_date = (old_move_date + relativedelta.relativedelta(days=delta.days or 0)).strftime(DEFAULT_SERVER_DATETIME_FORMAT) + new_move_date = (old_move_date + relativedelta.relativedelta(days=delta_days or 0)).strftime(DEFAULT_SERVER_DATETIME_FORMAT) propagated_changes_dict['date_expected'] = new_move_date #For pushed moves as well as for pulled moves, propagate by recursive call of write(). #Note that, for pulled moves we intentionally don't propagate on the procurement. @@ -521,9 +530,19 @@ class StockMove(models.Model): } def _do_unreserve(self): - if any(move.state in ('done', 'cancel') for move in self): - raise UserError(_('Cannot unreserve a done move')) - self.mapped('move_line_ids').unlink() + moves_to_unreserve = self.env['stock.move'] + for move in self: + if move.state == 'cancel': + # We may have cancelled move in an open picking in a "propagate_cancel" scenario. + continue + if move.state == 'done': + if move.scrapped: + # We may have done move in an open picking in a scrap scenario. + continue + else: + raise UserError(_('Cannot unreserve a done move')) + moves_to_unreserve |= move + moves_to_unreserve.mapped('move_line_ids').unlink() return True def _push_apply(self): @@ -618,7 +637,7 @@ class StockMove(models.Model): # We are using propagate to False in order to not cancel destination moves merged in moves[0] moves_to_unlink.write({'propagate': False}) moves_to_unlink._action_cancel() - moves_to_unlink.unlink() + moves_to_unlink.sudo().unlink() return (self | self.env['stock.move'].concat(*moves_to_merge)) - moves_to_unlink def _get_relevant_state_among_moves(self): @@ -679,7 +698,7 @@ class StockMove(models.Model): self.product_uom = product.uom_id.id return {'domain': {'product_uom': [('category_id', '=', product.uom_id.category_id.id)]}} - @api.onchange('date') + @api.onchange('date_expected') def onchange_date(self): if self.date_expected: self.date = self.date_expected @@ -805,7 +824,7 @@ class StockMove(models.Model): group_id = False return { 'company_id': self.company_id, - 'date_planned': self.date, + 'date_planned': self.date_expected, 'move_dest_ids': self, 'group_id': group_id, 'route_ids': self.route_ids, @@ -834,7 +853,12 @@ class StockMove(models.Model): } if quantity: uom_quantity = self.product_id.uom_id._compute_quantity(quantity, self.product_uom, rounding_method='HALF-UP') - vals = dict(vals, product_uom_qty=uom_quantity) + uom_quantity_back_to_product_uom = self.product_uom._compute_quantity(uom_quantity, self.product_id.uom_id, rounding_method='HALF-UP') + rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure') + if float_compare(quantity, uom_quantity_back_to_product_uom, precision_digits=rounding) == 0: + vals = dict(vals, product_uom_qty=uom_quantity) + else: + vals = dict(vals, product_uom_qty=quantity, product_uom_id=self.product_id.uom_id.id) if reserved_quant: vals = dict( vals, @@ -859,18 +883,32 @@ class StockMove(models.Model): taken_quantity = min(available_quantity, need) + # `taken_quantity` is in the quants unit of measure. There's a possibility that the move's + # unit of measure won't be respected if we blindly reserve this quantity, a common usecase + # is if the move's unit of measure's rounding does not allow fractional reservation. We chose + # to convert `taken_quantity` to the move's unit of measure with a down rounding method and + # then get it back in the quants unit of measure with an half-up rounding_method. This + # way, we'll never reserve more than allowed. We do not apply this logic if + # `available_quantity` is brought by a chained move line. In this case, `_prepare_move_line_vals` + # will take care of changing the UOM to the UOM of the product. + if not strict: + taken_quantity_move_uom = self.product_id.uom_id._compute_quantity(taken_quantity, self.product_uom, rounding_method='DOWN') + taken_quantity = self.product_uom._compute_quantity(taken_quantity_move_uom, self.product_id.uom_id, rounding_method='HALF-UP') + quants = [] + + if self.product_id.tracking == 'serial': + rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure') + if float_compare(taken_quantity, int(taken_quantity), precision_digits=rounding) != 0: + taken_quantity = 0 + try: - quants = self.env['stock.quant']._update_reserved_quantity( - self.product_id, location_id, taken_quantity, lot_id=lot_id, - package_id=package_id, owner_id=owner_id, strict=strict - ) + if not float_is_zero(taken_quantity, precision_rounding=self.product_id.uom_id.rounding): + quants = self.env['stock.quant']._update_reserved_quantity( + self.product_id, location_id, taken_quantity, lot_id=lot_id, + package_id=package_id, owner_id=owner_id, strict=strict + ) except UserError: - # If it raises here, it means that the `available_quantity` brought by a done move line - # is not available on the quants itself. This could be the result of an inventory - # adjustment that removed totally of partially `available_quantity`. When this happens, we - # chose to do nothing. This situation could not happen on MTS move, because in this case - # `available_quantity` is directly the quantity on the quants themselves. taken_quantity = 0 # Find a candidate move line to update or create a new one. @@ -878,7 +916,7 @@ class StockMove(models.Model): to_update = self.move_line_ids.filtered(lambda m: m.product_id.tracking != 'serial' and m.location_id.id == reserved_quant.location_id.id and m.lot_id.id == reserved_quant.lot_id.id and m.package_id.id == reserved_quant.package_id.id and m.owner_id.id == reserved_quant.owner_id.id) if to_update: - to_update[0].with_context(bypass_reservation_update=True).product_uom_qty += self.product_id.uom_id._compute_quantity(quantity, self.product_uom, rounding_method='HALF-UP') + to_update[0].with_context(bypass_reservation_update=True).product_uom_qty += self.product_id.uom_id._compute_quantity(quantity, to_update[0].product_uom_id, rounding_method='HALF-UP') else: if self.product_id.tracking == 'serial': for i in range(0, int(quantity)): @@ -896,7 +934,7 @@ class StockMove(models.Model): assigned_moves = self.env['stock.move'] partially_available_moves = self.env['stock.move'] for move in self.filtered(lambda m: m.state in ['confirmed', 'waiting', 'partially_available']): - if move.location_id.usage in ('supplier', 'inventory', 'production', 'customer')\ + if move.location_id.should_bypass_reservation()\ or move.product_id.type == 'consu': # create the move line(s) but do not impact quants if move.product_id.tracking == 'serial' and (move.picking_type_id.use_create_lots or move.picking_type_id.use_existing_lots): @@ -919,12 +957,18 @@ class StockMove(models.Model): if not move.move_orig_ids: if move.procure_method == 'make_to_order': continue + # If we don't need any quantity, consider the move assigned. + need = move.product_qty - move.reserved_availability + if float_is_zero(need, precision_rounding=move.product_id.uom_id.rounding): + assigned_moves |= move + continue # Reserve new quants and create move lines accordingly. available_quantity = self.env['stock.quant']._get_available_quantity(move.product_id, move.location_id) if available_quantity <= 0: continue - need = move.product_qty - move.reserved_availability taken_quantity = move._update_reserved_quantity(need, available_quantity, move.location_id, strict=False) + if float_is_zero(taken_quantity, precision_rounding=move.product_id.uom_id.rounding): + continue if need == taken_quantity: assigned_moves |= move else: @@ -980,7 +1024,19 @@ class StockMove(models.Model): available_move_lines[(move_line.location_id, move_line.lot_id, move_line.result_package_id, move_line.owner_id)] -= move_line.product_qty for (location_id, lot_id, package_id, owner_id), quantity in available_move_lines.items(): need = move.product_qty - sum(move.move_line_ids.mapped('product_qty')) - taken_quantity = move._update_reserved_quantity(need, quantity, location_id, lot_id, package_id, owner_id) + # `quantity` is what is brought by chained done move lines. We double check + # here this quantity is available on the quants themselves. If not, this + # could be the result of an inventory adjustment that removed totally of + # partially `quantity`. When this happens, we chose to reserve the maximum + # still available. This situation could not happen on MTS move, because in + # this case `quantity` is directly the quantity on the quants themselves. + available_quantity = self.env['stock.quant']._get_available_quantity( + move.product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True) + if float_is_zero(available_quantity, precision_rounding=move.product_id.uom_id.rounding): + continue + taken_quantity = move._update_reserved_quantity(need, min(quantity, available_quantity), location_id, lot_id, package_id, owner_id) + if float_is_zero(taken_quantity, precision_rounding=move.product_id.uom_id.rounding): + continue if need - taken_quantity == 0.0: assigned_moves |= move break @@ -1031,8 +1087,8 @@ class StockMove(models.Model): # create the extra moves extra_move_quantity = float_round( self.quantity_done - self.product_uom_qty, - precision_rounding=self.product_uom.rounding, - rounding_method ='UP') + precision_rounding=rounding, + rounding_method='HALF-UP') extra_move_vals = self._prepare_extra_move_vals(extra_move_quantity) extra_move = self.copy(default=extra_move_vals) if extra_move.picking_id: @@ -1060,6 +1116,9 @@ class StockMove(models.Model): break return extra_move + def _unreserve_initial_demand(self, new_move): + pass + def check_move_bal_qty(self): for move in self: if move.move_type == 'in': @@ -1106,8 +1165,10 @@ class StockMove(models.Model): # Split moves where necessary and move quants for move in moves_todo: - rounding = move.product_uom.rounding - if float_compare(move.quantity_done, move.product_uom_qty, precision_rounding=rounding) < 0: + # To know whether we need to create a backorder or not, round to the general product's + # decimal precision and not the product's UOM. + rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure') + if float_compare(move.quantity_done, move.product_uom_qty, precision_digits=rounding) < 0: # Need to do some kind of conversion here qty_split = move.product_uom._compute_quantity(move.product_uom_qty - move.quantity_done, move.product_id.uom_id, rounding_method='HALF-UP') new_move = move._split(qty_split) @@ -1120,9 +1181,7 @@ class StockMove(models.Model): move_line.write({'product_uom_qty': move_line.qty_done}) except UserError: pass - - # If you were already putting stock.move.lots on the next one in the work order, transfer those to the new move - move.move_line_ids.filtered(lambda x: x.qty_done == 0.0).write({'move_id': new_move}) + move._unreserve_initial_demand(new_move) move.move_line_ids._action_done() # Check the consistency of the result packages; there should be an unique location across # the contained quants. @@ -1154,15 +1213,17 @@ class StockMove(models.Model): self.mapped('move_line_ids').unlink() return super(StockMove, self).unlink() - def _prepare_move_split_vals(self, uom_qty): + def _prepare_move_split_vals(self, qty): vals = { - 'product_uom_qty': uom_qty, + 'product_uom_qty': qty, 'procure_method': 'make_to_stock', 'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if x.state not in ('done', 'cancel')], 'move_orig_ids': [(4, x.id) for x in self.move_orig_ids], 'origin_returned_move_id': self.origin_returned_move_id.id, 'price_unit': self.price_unit, } + if self.env.context.get('force_split_uom_id'): + vals['product_uom'] = self.env.context['force_split_uom_id'] return vals def _split(self, qty, restrict_partner_id=False): @@ -1181,9 +1242,18 @@ class StockMove(models.Model): raise UserError(_('You cannot split a draft move. It needs to be confirmed first.')) if float_is_zero(qty, precision_rounding=self.product_id.uom_id.rounding) or self.product_qty <= qty: return self.id - # HALF-UP rounding as only rounding errors will be because of propagation of error from default UoM + + decimal_precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + + # `qty` passed as argument is the quantity to backorder and is always expressed in the + # quants UOM. If we're able to convert back and forth this quantity in the move's and the + # quants UOM, the backordered move can keep the UOM of the move. Else, we'll create is in + # the UOM of the quants. uom_qty = self.product_id.uom_id._compute_quantity(qty, self.product_uom, rounding_method='HALF-UP') - defaults = self._prepare_move_split_vals(uom_qty) + if float_compare(qty, self.product_uom._compute_quantity(uom_qty, self.product_id.uom_id, rounding_method='HALF-UP'), precision_digits=decimal_precision) == 0: + defaults = self._prepare_move_split_vals(uom_qty) + else: + defaults = self.with_context(force_split_uom_id=self.product_id.uom_id.id)._prepare_move_split_vals(qty) if restrict_partner_id: defaults['restrict_partner_id'] = restrict_partner_id @@ -1192,21 +1262,15 @@ class StockMove(models.Model): if self.env.context.get('source_location_id'): defaults['location_id'] = self.env.context['source_location_id'] new_move = self.with_context(rounding_method='HALF-UP').copy(defaults) - # ctx = context.copy() - # TDE CLEANME: used only in write in this file, to clean - # ctx['do_not_propagate'] = True # FIXME: pim fix your crap - self.with_context(do_not_propagate=True, do_not_unreserve=True, rounding_method='HALF-UP').write({'product_uom_qty': self.product_uom_qty - uom_qty}) - - # if self.move_dest_id and self.propagate and self.move_dest_id.state not in ('done', 'cancel'): - # new_move_prop = self.move_dest_id.split(qty) - # new_move.write({'move_dest_id': new_move_prop}) - # returning the first element of list returned by action_confirm is ok because we checked it wouldn't be exploded (and - # thus the result of action_confirm should always be a list of 1 element length) - # In this case we don't merge move since the new move with 0 quantity done will be used for the backorder. + # Update the original `product_qty` of the move. Use the general product's decimal + # precision and not the move's UOM to handle case where the `quantity_done` is not + # compatible with the move's UOM. + new_product_qty = self.product_id.uom_id._compute_quantity(self.product_qty - qty, self.product_uom, round=False) + new_product_qty = float_round(new_product_qty, precision_digits=self.env['decimal.precision'].precision_get('Product Unit of Measure')) + self.with_context(do_not_propagate=True, do_not_unreserve=True, rounding_method='HALF-UP').write({'product_uom_qty': new_product_qty}) new_move = new_move._action_confirm(merge=False) - # TDE FIXME: due to action confirm change return new_move.id def _recompute_state(self): diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index d54fa45d..a2829c6b 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -52,7 +52,6 @@ class StockMoveLine(models.Model): reference = fields.Char(related='move_id.reference', store=True) in_entire_package = fields.Boolean(compute='_compute_in_entire_package') - @api.one def _compute_location_description(self): for operation, operation_sudo in izip(self, self.sudo()): operation.from_loc = '%s%s' % (operation_sudo.location_id.name, operation.product_id and operation_sudo.package_id.name or '') @@ -141,7 +140,7 @@ class StockMoveLine(models.Model): help him. This onchange will warn him if he set `qty_done` to a non-supported value. """ res = {} - if self.product_id.tracking == 'serial': + if self.qty_done and self.product_id.tracking == 'serial': if float_compare(self.qty_done, 1.0, precision_rounding=self.move_id.product_id.uom_id.rounding) != 0: message = _('You can only process 1.0 %s for products with unique serial number.') % self.product_id.uom_id.name res['warning'] = {'title': _('Warning'), 'message': message} @@ -269,7 +268,7 @@ class StockMoveLine(models.Model): except UserError: pass if new_product_qty != ml.product_qty: - new_product_uom_qty = self.product_id.uom_id._compute_quantity(new_product_qty, self.product_uom_id, rounding_method='HALF-UP') + new_product_uom_qty = ml.product_id.uom_id._compute_quantity(new_product_qty, ml.product_uom_id, rounding_method='HALF-UP') ml.with_context(bypass_reservation_update=True).product_uom_qty = new_product_uom_qty # When editing a done move line, the reserved availability of a potential chained move is impacted. Take care of running again `_action_assign` on the concerned moves. @@ -370,6 +369,15 @@ class StockMoveLine(models.Model): # `action_done` on the next move lines. ml_to_delete = self.env['stock.move.line'] for ml in self: + # Check here if `ml.qty_done` respects the rounding of `ml.product_uom_id`. + uom_qty = float_round(ml.qty_done, precision_rounding=ml.product_uom_id.rounding, rounding_method='HALF-UP') + precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') + qty_done = float_round(ml.qty_done, precision_digits=precision_digits, rounding_method='HALF-UP') + if float_compare(uom_qty, qty_done, precision_digits=precision_digits) != 0: + raise UserError(_('The quantity done for the product "%s" doesn\'t respect the rounding precision \ + defined on the unit of measure "%s". Please change the quantity done or the \ + rounding precision of your unit of measure.') % (ml.product_id.display_name, ml.product_uom_id.name)) + qty_done_float_compared = float_compare(ml.qty_done, 0, precision_rounding=ml.product_uom_id.rounding) if qty_done_float_compared > 0: if ml.product_id.tracking != 'none': diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index b88dac02..7ff86d8e 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -320,7 +320,7 @@ class Picking(models.Model): for picking in self: if self.env.context.get('force_detailed_view'): picking.show_operations = True - break + continue if picking.picking_type_id.show_operations: if (picking.state == 'draft' and not self.env.context.get('planned_picking')) or picking.state != 'draft': picking.show_operations = True @@ -669,6 +669,7 @@ class Picking(models.Model): all_in = True pack_move_lines = self.move_line_ids.filtered(lambda ml: ml.package_id == package) keys = ['product_id', 'lot_id'] + precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') grouped_quants = {} for k, g in groupby(sorted(package.quant_ids, key=itemgetter(*keys)), key=itemgetter(*keys)): @@ -677,8 +678,8 @@ class Picking(models.Model): grouped_ops = {} for k, g in groupby(sorted(pack_move_lines, key=itemgetter(*keys)), key=itemgetter(*keys)): grouped_ops[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty')) - if any(grouped_quants.get(key, 0) - grouped_ops.get(key, 0) != 0 for key in grouped_quants) \ - or any(grouped_ops.get(key, 0) - grouped_quants.get(key, 0) != 0 for key in grouped_ops): + if any(not float_is_zero(grouped_quants.get(key, 0) - grouped_ops.get(key, 0), precision_digits=precision_digits) for key in grouped_quants) \ + or any(not float_is_zero(grouped_ops.get(key, 0) - grouped_quants.get(key, 0), precision_digits=precision_digits) for key in grouped_ops): all_in = False return all_in @@ -704,7 +705,8 @@ class Picking(models.Model): # If no lots when needed, raise error picking_type = self.picking_type_id - no_quantities_done = all(float_is_zero(move_line.qty_done, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids) + precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') + no_quantities_done = all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in self.move_line_ids) no_reserved_quantities = all(float_is_zero(move_line.product_qty, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids) if no_reserved_quantities and no_quantities_done: raise UserError(_('You cannot validate a transfer if you have not processed any quantity. You should rather cancel the transfer.')) @@ -760,7 +762,7 @@ class Picking(models.Model): if self._check_backorder(): return self.action_generate_backorder_wizard() self.action_done() - return + return True def action_generate_backorder_wizard(self): view = self.env.ref('stock.view_backorder_confirmation') diff --git a/addons/stock/models/stock_production_lot.py b/addons/stock/models/stock_production_lot.py index 7d1d54d7..cdd8d870 100644 --- a/addons/stock/models/stock_production_lot.py +++ b/addons/stock/models/stock_production_lot.py @@ -37,6 +37,18 @@ class ProductionLot(models.Model): raise UserError(_("You are not allowed to create a lot for this picking type")) return super(ProductionLot, self).create(vals) + @api.multi + def write(self, vals): + if 'product_id' in vals: + move_lines = self.env['stock.move.line'].search([('lot_id', 'in', self.ids)]) + if move_lines: + raise UserError(_( + 'You are not allowed to change the product linked to a serial or lot number ' + + 'if some stock moves have already been created with that number. ' + + 'This would lead to inconsistencies in your stock.' + )) + return super(ProductionLot, self).write(vals) + @api.one def _product_qty(self): # We only care for the quants in internal or transit locations. diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index 15b11270..95d183a2 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -81,12 +81,6 @@ class StockQuant(models.Model): if float_compare(quant.quantity, 1, precision_rounding=quant.product_uom_id.rounding) > 0 and quant.lot_id and quant.product_id.tracking == 'serial': raise ValidationError(_('A serial number should only be linked to a single product.')) - @api.constrains('in_date', 'lot_id') - def check_in_date(self): - for quant in self: - if quant.in_date and not quant.lot_id: - raise ValidationError(_('An incoming date cannot be set to an untracked product.')) - @api.constrains('location_id') def check_location_id(self): for quant in self: @@ -111,9 +105,9 @@ class StockQuant(models.Model): @api.model def _get_removal_strategy_order(self, removal_strategy): if removal_strategy == 'fifo': - return 'in_date, id' + return 'in_date ASC NULLS FIRST, id' elif removal_strategy == 'lifo': - return 'in_date desc, id desc' + return 'in_date DESC NULLS LAST, id desc' raise UserError(_('Removal strategy %s not implemented.') % (removal_strategy,)) def _gather(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False): @@ -136,7 +130,17 @@ class StockQuant(models.Model): domain = expression.AND([[('owner_id', '=', owner_id and owner_id.id or False)], domain]) domain = expression.AND([[('location_id', '=', location_id.id)], domain]) - return self.search(domain, order=removal_strategy_order) + # Copy code of _search for special NULLS FIRST/LAST order + self.sudo(self._uid).check_access_rights('read') + query = self._where_calc(domain) + self._apply_ir_rules(query, 'read') + from_clause, where_clause, where_clause_params = query.get_sql() + where_str = where_clause and (" WHERE %s" % where_clause) or '' + query_str = 'SELECT "%s".id FROM ' % self._table + from_clause + where_str + " ORDER BY "+ removal_strategy_order + self._cr.execute(query_str, where_clause_params) + res = self._cr.fetchall() + # No uniquify list necessary as auto_join is not applied anyways... + return self.browse([x[0] for x in res]) @api.model def _get_available_quantity(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, allow_negative=False): @@ -198,17 +202,16 @@ class StockQuant(models.Model): quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True) rounding = product_id.uom_id.rounding - if lot_id: - incoming_dates = quants.mapped('in_date') # `mapped` already filtered out falsy items - incoming_dates = [fields.Datetime.from_string(incoming_date) for incoming_date in incoming_dates] - if in_date: - incoming_dates += [in_date] - # If multiple incoming dates are available for a given lot_id/package_id/owner_id, we - # consider only the oldest one as being relevant. - if incoming_dates: - in_date = fields.Datetime.to_string(min(incoming_dates)) - else: - in_date = fields.Datetime.now() + incoming_dates = [d for d in quants.mapped('in_date') if d] + incoming_dates = [fields.Datetime.from_string(incoming_date) for incoming_date in incoming_dates] + if in_date: + incoming_dates += [in_date] + # If multiple incoming dates are available for a given lot_id/package_id/owner_id, we + # consider only the oldest one as being relevant. + if incoming_dates: + in_date = fields.Datetime.to_string(min(incoming_dates)) + else: + in_date = fields.Datetime.now() for quant in quants: try: @@ -255,13 +258,21 @@ class StockQuant(models.Model): self = self.sudo() rounding = product_id.uom_id.rounding quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict) - available_quantity = self._get_available_quantity(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict) - if float_compare(quantity, 0, precision_rounding=rounding) > 0 and float_compare(quantity, available_quantity, precision_rounding=rounding) > 0: - raise UserError(_('It is not possible to reserve more products of %s than you have in stock.') % (', '.join(quants.mapped('product_id').mapped('display_name')))) - elif float_compare(quantity, 0, precision_rounding=rounding) < 0 and float_compare(abs(quantity), sum(quants.mapped('reserved_quantity')), precision_rounding=rounding) > 0: - raise UserError(_('It is not possible to unreserve more products of %s than you have in stock.') % (', '.join(quants.mapped('product_id').mapped('display_name')))) - reserved_quants = [] + + if float_compare(quantity, 0, precision_rounding=rounding) > 0: + # if we want to reserve + available_quantity = self._get_available_quantity(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict) + if float_compare(quantity, available_quantity, precision_rounding=rounding) > 0: + raise UserError(_('It is not possible to reserve more products of %s than you have in stock.') % (', '.join(quants.mapped('product_id').mapped('display_name')))) + elif float_compare(quantity, 0, precision_rounding=rounding) < 0: + # if we want to unreserve + available_quantity = sum(quants.mapped('reserved_quantity')) + if float_compare(abs(quantity), available_quantity, precision_rounding=rounding) > 0: + raise UserError(_('It is not possible to unreserve more products of %s than you have in stock.') % (', '.join(quants.mapped('product_id').mapped('display_name')))) + else: + return reserved_quants + for quant in quants: if float_compare(quantity, 0, precision_rounding=rounding) > 0: max_quantity_on_quant = quant.quantity - quant.reserved_quantity @@ -450,6 +461,12 @@ class QuantPackage(models.Model): if move_lines_to_remove: move_lines_to_remove.write({'result_package_id': False}) else: + move_line_to_modify = self.env['stock.move.line'].search([ + ('package_id', '=', package.id), + ('state', 'in', ('assigned', 'partially_available')), + ('product_qty', '!=', 0), + ]) + move_line_to_modify.write({'package_id': False}) package.mapped('quant_ids').write({'package_id': False}) def action_view_picking(self): diff --git a/addons/stock/models/stock_traceability.py b/addons/stock/models/stock_traceability.py index baa123e5..6bfdc37d 100644 --- a/addons/stock/models/stock_traceability.py +++ b/addons/stock/models/stock_traceability.py @@ -21,47 +21,55 @@ class MrpStockReport(models.TransientModel): @api.model def get_move_lines_upstream(self, move_lines): - res = self.env['stock.move.line'] - for move_line in move_lines: + lines_seen = move_lines + lines_todo = list(move_lines) + while lines_todo: + move_line = lines_todo.pop(0) # if MTO if move_line.move_id.move_orig_ids: - res |= move_line.move_id.move_orig_ids.mapped('move_line_ids').filtered( - lambda m: m.lot_id.id == move_line.lot_id.id) + lines = move_line.move_id.move_orig_ids.mapped('move_line_ids').filtered( + lambda m: m.lot_id == move_line.lot_id + ) - lines_seen # if MTS + elif move_line.location_id.usage == 'internal': + lines = self.env['stock.move.line'].search([ + ('product_id', '=', move_line.product_id.id), + ('lot_id', '=', move_line.lot_id.id), + ('location_dest_id', '=', move_line.location_id.id), + ('id', 'not in', lines_seen.ids), + ('date', '<', move_line.date), + ]) else: - if move_line.location_id.usage == 'internal': - res |= self.env['stock.move.line'].search([ - ('product_id', '=', move_line.product_id.id), - ('lot_id', '=', move_line.lot_id.id), - ('location_dest_id', '=', move_line.location_id.id), - ('id', '!=', move_line.id), - ('date', '<', move_line.date), - ]) - if res: - res |= self.get_move_lines_upstream(res) - return res + continue + lines_todo += list(lines) + lines_seen |= lines + return lines_seen - move_lines @api.model def get_move_lines_downstream(self, move_lines): - res = self.env['stock.move.line'] - for move_line in move_lines: + lines_seen = move_lines + lines_todo = list(move_lines) + while lines_todo: + move_line = lines_todo.pop(0) # if MTO if move_line.move_id.move_dest_ids: - res |= move_line.move_id.move_dest_ids.mapped('move_line_ids').filtered( - lambda m: m.lot_id.id == move_line.lot_id.id) + lines = move_line.move_id.move_dest_ids.mapped('move_line_ids').filtered( + lambda m: m.lot_id == move_line.lot_id + ) - lines_seen # if MTS + elif move_line.location_dest_id.usage == 'internal': + lines = self.env['stock.move.line'].search([ + ('product_id', '=', move_line.product_id.id), + ('lot_id', '=', move_line.lot_id.id), + ('location_id', '=', move_line.location_dest_id.id), + ('id', 'not in', lines_seen.ids), + ('date', '>', move_line.date), + ]) else: - if move_line.location_dest_id.usage == 'internal': - res |= self.env['stock.move.line'].search([ - ('product_id', '=', move_line.product_id.id), - ('lot_id', '=', move_line.lot_id.id), - ('location_id', '=', move_line.location_dest_id.id), - ('id', '!=', move_line.id), - ('date', '>', move_line.date), - ]) - if res: - res |= self.get_move_lines_downstream(res) - return res + continue + lines_todo += list(lines) + lines_seen |= lines + return lines_seen - move_lines @api.model def get_lines(self, line_id=None, **kw): diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 43c43ac8..9d90331d 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -163,11 +163,14 @@ class Warehouse(models.Model): # If another partner assigned if vals.get('partner_id'): warehouses._update_partner_data(vals['partner_id'], vals.get('company_id')) + res = super(Warehouse, self).write(vals) # check if we need to delete and recreate route if vals.get('reception_steps') or vals.get('delivery_steps'): - warehouses._update_routes() + route_vals = warehouses._update_routes() + if route_vals: + self.write(route_vals) if vals.get('resupply_wh_ids') and not vals.get('resupply_route_ids'): for warehouse in warehouses: @@ -188,9 +191,10 @@ class Warehouse(models.Model): ResCompany = self.env['res.company'] if company_id: transit_loc = ResCompany.browse(company_id).internal_transit_location_id.id + self.env['res.partner'].browse(partner_id).with_context(force_company=company_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc}) else: transit_loc = ResCompany._company_default_get('stock.warehouse').internal_transit_location_id.id - self.env['res.partner'].browse(partner_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc}) + self.env['res.partner'].browse(partner_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc}) def create_sequences_and_picking_types(self): IrSequenceSudo = self.env['ir.sequence'].sudo() @@ -293,7 +297,7 @@ class Warehouse(models.Model): reception_route.pull_ids.write({'active': False}) reception_route.push_ids.write({'active': False}) else: - reception_route = self.env['stock.location.route'].create(warehouse._get_reception_delivery_route_values(warehouse.reception_steps)) + warehouse.reception_route_id = reception_route = self.env['stock.location.route'].create(warehouse._get_reception_delivery_route_values(warehouse.reception_steps)) # push / procurement (pull) rules for reception routings = routes_data[warehouse.id][warehouse.reception_steps] push_rules_list, pull_rules_list = warehouse._get_push_pull_rules_values( @@ -406,7 +410,7 @@ class Warehouse(models.Model): pull_rules_list = supplier_wh._get_supply_pull_rules_values( [self.Routing(output_location, transit_location, supplier_wh.out_type_id)], - values={'route_id': inter_wh_route.id, 'propagate_warehouse_id': self.id}) + values={'route_id': inter_wh_route.id}) pull_rules_list += self._get_supply_pull_rules_values( [self.Routing(transit_location, input_location, self.in_type_id)], values={'route_id': inter_wh_route.id, 'propagate_warehouse_id': supplier_wh.id}) @@ -606,11 +610,18 @@ class Warehouse(models.Model): routes_data = self.get_routes_dict() # change the default source and destination location and (de)activate operation types self._update_picking_type() - self._create_or_update_delivery_route(routes_data) - self._create_or_update_reception_route(routes_data) - self._create_or_update_crossdock_route(routes_data) - self._create_or_update_mto_pull(routes_data) - return True + delivery_route = self._create_or_update_delivery_route(routes_data) + reception_route = self._create_or_update_reception_route(routes_data) + crossdock_route = self._create_or_update_crossdock_route(routes_data) + mto_pull = self._create_or_update_mto_pull(routes_data) + + return { + 'route_ids': [(4, route.id) for route in reception_route | delivery_route | crossdock_route], + 'mto_pull_id': mto_pull.id, + 'reception_route_id': reception_route.id, + 'delivery_route_id': delivery_route.id, + 'crossdock_route_id': crossdock_route.id, + } @api.one def _update_picking_type(self): diff --git a/addons/stock/report/report_location_barcode.xml b/addons/stock/report/report_location_barcode.xml index 3ca5405b..11e5f8f4 100644 --- a/addons/stock/report/report_location_barcode.xml +++ b/addons/stock/report/report_location_barcode.xml @@ -9,8 +9,8 @@