diff --git a/addons/purchase_stock/tests/test_purchase_order.py b/addons/purchase_stock/tests/test_purchase_order.py index a2b26a11ec2..b267920200e 100644 --- a/addons/purchase_stock/tests/test_purchase_order.py +++ b/addons/purchase_stock/tests/test_purchase_order.py @@ -376,6 +376,54 @@ class TestPurchaseOrder(ValuationReconciliationTestCommon): picking.button_validate() self.assertEqual(po.order_line.mapped('qty_received'), [4.0], 'Purchase: no conversion error on receipt in different uom"') + def test_05_po_update_qty_stock_move_merge(self): + """ This test ensures that changing product quantity when unit price has high decimal precision + merged with the original instead of creating a new return + """ + + unit_price_precision = self.env['decimal.precision'].search([('name', '=', 'Product Price')]) + unit_price_precision.digits = 6 + + tax = self.env["account.tax"].create({ + "name": "Dummy Tax", + "amount": "5.00", + "type_tax_use": "purchase", + }) + + super_product = self.env['product.product'].create({ + 'name': 'Super Product', + 'type': 'product', + 'categ_id': self.stock_account_product_categ.id, + 'standard_price': 9.876543, + }) + + purchase_order = self.env['purchase.order'].create({ + 'partner_id': self.env.ref('base.res_partner_3').id, + 'order_line': [(0, 0, { + 'name': super_product.name, + 'product_id': super_product.id, + 'product_qty': 7, + 'product_uom': super_product.uom_id.id, + 'price_unit': super_product.standard_price, + 'taxes_id': [(4, tax.id)], + })], + }) + + purchase_order.button_confirm() + self.assertEqual(purchase_order.state, 'purchase') + self.assertEqual(len(purchase_order.picking_ids), 1) + self.assertEqual(len(purchase_order.picking_ids.move_line_ids), 1) + self.assertEqual(purchase_order.picking_ids.move_line_ids.reserved_qty, 7) + + + purchase_order.order_line.product_qty = 4 + # updating quantity shouldn't create a seperate stock move + # the new stock move (-3) should be merged with the previous + purchase_order.button_confirm() + self.assertEqual(len(purchase_order.picking_ids), 1) + self.assertEqual(len(purchase_order.picking_ids.move_line_ids), 1) + self.assertEqual(purchase_order.picking_ids.move_line_ids.reserved_qty, 4) + def test_message_qty_already_received(self): self.env.user.write({'company_id': self.company_data['company'].id}) diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index d406488180d..2660f9e2272 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -894,7 +894,7 @@ Please change the quantity done or the rounding precision of your unit of measur @api.model def _prepare_merge_negative_moves_excluded_distinct_fields(self): - return ['description_picking'] + return ['description_picking', 'price_unit'] def _clean_merged(self): """Cleanup hook used when merging moves""" @@ -932,6 +932,7 @@ Please change the quantity done or the rounding precision of your unit of measur neg_qty_moves.picking_id = False excluded_fields = self._prepare_merge_negative_moves_excluded_distinct_fields() neg_key = itemgetter(*[field for field in distinct_fields if field not in excluded_fields]) + price_unit_prec = self.env['decimal.precision'].precision_get('Product Price') for candidate_moves in candidate_moves_set: # First step find move to merge. @@ -953,21 +954,27 @@ Please change the quantity done or the rounding precision of your unit of measur for neg_move in neg_qty_moves: # Check all the candidates that matches the same limited key, and adjust their quantities to absorb negative moves for pos_move in moves_by_neg_key.get(neg_key(neg_move), []): - # If quantity can be fully absorbed by a single move, update its quantity and remove the negative move - if float_compare(pos_move.product_uom_qty, abs(neg_move.product_uom_qty), precision_rounding=pos_move.product_uom.rounding) >= 0: - pos_move.product_uom_qty += neg_move.product_uom_qty - pos_move.write({ - 'move_dest_ids': [Command.link(m.id) for m in neg_move.mapped('move_dest_ids') if m.location_id == pos_move.location_dest_id], - 'move_orig_ids': [Command.link(m.id) for m in neg_move.mapped('move_orig_ids') if m.location_dest_id == pos_move.location_id], - }) - merged_moves |= pos_move - moves_to_unlink |= neg_move - if float_is_zero(pos_move.product_uom_qty, precision_rounding=pos_move.product_uom.rounding): - moves_to_cancel |= pos_move - break - neg_move.product_uom_qty += pos_move.product_uom_qty - pos_move.product_uom_qty = 0 - moves_to_cancel |= pos_move + currency_prec = pos_move.product_id.currency_id.decimal_places + rounding = min(currency_prec, price_unit_prec) + if float_compare(pos_move.price_unit, neg_move.price_unit, precision_rounding=rounding) == 0: + new_total_value = pos_move.product_qty * pos_move.price_unit + neg_move.product_qty * neg_move.price_unit + # If quantity can be fully absorbed by a single move, update its quantity and remove the negative move + if float_compare(pos_move.product_uom_qty, abs(neg_move.product_uom_qty), precision_rounding=pos_move.product_uom.rounding) >= 0: + pos_move.product_uom_qty += neg_move.product_uom_qty + pos_move.write({ + 'price_unit': float_round(new_total_value / pos_move.product_qty, precision_digits=price_unit_prec) if pos_move.product_qty else 0, + 'move_dest_ids': [Command.link(m.id) for m in neg_move.mapped('move_dest_ids') if m.location_id == pos_move.location_dest_id], + 'move_orig_ids': [Command.link(m.id) for m in neg_move.mapped('move_orig_ids') if m.location_dest_id == pos_move.location_id], + }) + merged_moves |= pos_move + moves_to_unlink |= neg_move + if float_is_zero(pos_move.product_uom_qty, precision_rounding=pos_move.product_uom.rounding): + moves_to_cancel |= pos_move + break + neg_move.product_uom_qty += pos_move.product_uom_qty + neg_move.price_unit = float_round(new_total_value / neg_move.product_qty, precision_digits=price_unit_prec) + pos_move.product_uom_qty = 0 + moves_to_cancel |= pos_move if moves_to_unlink: # We are using propagate to False in order to not cancel destination moves merged in moves[0]