From f2a746ad6ad7a7b8d261a5a15e1db36ffa47c566 Mon Sep 17 00:00:00 2001 From: Adrien Widart Date: Thu, 3 Mar 2022 13:46:52 +0000 Subject: [PATCH] [FIX] sale_mrp: return a dropshipped kit On a SO, the delivered quantity of a dropshipped kit is incorrect in case of a return To reproduce the issue: 1. Activate Dropshipping 2. Create 3 products: KIT AB, A, B: - All have the Dropship route and a supplier set 3. Create a BOM of type kit: KIT AB is composed of A & B 4. Create a SO for 1 unit of KIT AB, validate 5. Validate the created PO 6. Deliver the products 7. Create a return for these products - Error: On the SO, the delivered quantity is 0 but the return is not validated yet. This quantity should still be 1 8. Validate the return - Error: On the SO, the delivered quantity is now 1, it should be 0 The 'all or nothing' policy should correctly consider the returns: all moves (to customer) must be done and the returns must be delivered back to the customer OPW-2759250 closes odoo/odoo#87596 X-original-commit: f259d10db577bbf1b11fffd028e678b38d5f402d Signed-off-by: Tiffany Chang Signed-off-by: Adrien Widart --- .../tests/test_sale_dropshipping.py | 179 ++++++++++++++++++ addons/sale_mrp/models/sale_order_line.py | 17 +- 2 files changed, 191 insertions(+), 5 deletions(-) diff --git a/addons/mrp_subcontracting_dropshipping/tests/test_sale_dropshipping.py b/addons/mrp_subcontracting_dropshipping/tests/test_sale_dropshipping.py index 71d2ad613ea..d4062ddd437 100644 --- a/addons/mrp_subcontracting_dropshipping/tests/test_sale_dropshipping.py +++ b/addons/mrp_subcontracting_dropshipping/tests/test_sale_dropshipping.py @@ -12,6 +12,7 @@ class TestSaleDropshippingFlows(TestMrpSubcontractingCommon): def setUpClass(cls): super().setUpClass() + cls.supplier = cls.env["res.partner"].create({"name": "Supplier"}) cls.customer = cls.env["res.partner"].create({"name": "Customer"}) cls.dropship_route = cls.env.ref('stock_dropshipping.route_drop_shipping') @@ -71,3 +72,181 @@ class TestSaleDropshippingFlows(TestMrpSubcontractingCommon): # Cancel the second one sale_order.picking_ids[1].action_cancel() self.assertEqual(sale_order.order_line.qty_delivered, 1) + + def test_return_kit_and_delivered_qty(self): + """ + Sell a kit thanks to the dropshipping route, return it then deliver it again + The delivered quantity should be correctly computed + """ + compo, kit = self.env['product.product'].create([{ + 'name': n, + 'type': 'consu', + 'route_ids': [(6, 0, [self.dropship_route.id])], + 'seller_ids': [(0, 0, {'partner_id': self.supplier.id})], + } for n in ['Compo', 'Kit']]) + + self.env['mrp.bom'].create({ + 'product_tmpl_id': kit.product_tmpl_id.id, + 'product_qty': 1, + 'type': 'phantom', + 'bom_line_ids': [ + (0, 0, {'product_id': compo.id, 'product_qty': 1}), + ], + }) + + sale_order = self.env['sale.order'].create({ + 'partner_id': self.customer.id, + 'picking_policy': 'direct', + 'order_line': [ + (0, 0, {'name': kit.name, 'product_id': kit.id, 'product_uom_qty': 1}), + ], + }) + sale_order.action_confirm() + self.env['purchase.order'].search([], order='id desc', limit=1).button_confirm() + self.assertEqual(sale_order.order_line.qty_delivered, 0.0) + + picking = sale_order.picking_ids + action = picking.button_validate() + wizard = Form(self.env[action['res_model']].with_context(action['context'])).save() + wizard.process() + self.assertEqual(sale_order.order_line.qty_delivered, 1.0) + + for case in ['return', 'deliver again']: + delivered_before_case = 1.0 if case == 'return' else 0.0 + delivered_after_case = 0.0 if case == 'return' else 1.0 + return_form = Form(self.env['stock.return.picking'].with_context(active_ids=[picking.id], active_id=picking.id, active_model='stock.picking')) + return_wizard = return_form.save() + action = return_wizard.create_returns() + picking = self.env['stock.picking'].browse(action['res_id']) + self.assertEqual(sale_order.order_line.qty_delivered, delivered_before_case, "Incorrect delivered qty for case '%s'" % case) + + action = picking.button_validate() + wizard = Form(self.env[action['res_model']].with_context(action['context'])).save() + wizard.process() + self.assertEqual(sale_order.order_line.qty_delivered, delivered_after_case, "Incorrect delivered qty for case '%s'" % case) + + def test_partial_return_kit_and_delivered_qty(self): + """ + Suppose a kit with 4x the same dropshipped component + Suppose a complex delivery process: + - Deliver 2 (with backorder) + - Return 2 + - Deliver 1 (with backorder) + - Deliver 1 (process "done") + - Deliver 1 (from the return) + - Deliver 1 (from the return) + The test checks the all-or-nothing policy of the delivered quantity + This quantity should be 1.0 after the last delivery + """ + compo, kit = self.env['product.product'].create([{ + 'name': n, + 'type': 'consu', + 'route_ids': [(6, 0, [self.dropship_route.id])], + 'seller_ids': [(0, 0, {'partner_id': self.supplier.id})], + } for n in ['Compo', 'Kit']]) + + self.env['mrp.bom'].create({ + 'product_tmpl_id': kit.product_tmpl_id.id, + 'product_qty': 1, + 'type': 'phantom', + 'bom_line_ids': [ + (0, 0, {'product_id': compo.id, 'product_qty': 4}), + ], + }) + + sale_order = self.env['sale.order'].create({ + 'partner_id': self.customer.id, + 'picking_policy': 'direct', + 'order_line': [ + (0, 0, {'name': kit.name, 'product_id': kit.id, 'product_uom_qty': 1}), + ], + }) + sale_order.action_confirm() + self.env['purchase.order'].search([], order='id desc', limit=1).button_confirm() + self.assertEqual(sale_order.order_line.qty_delivered, 0.0, "Delivered components: 0/4") + + picking01 = sale_order.picking_ids + picking01.move_ids.quantity_done = 2 + action = picking01.button_validate() + wizard = Form(self.env[action['res_model']].with_context(action['context'])).save() + wizard.process() + self.assertEqual(sale_order.order_line.qty_delivered, 0.0, "Delivered components: 2/4") + + # Create a return of picking01 (with both components) + return_form = Form(self.env['stock.return.picking'].with_context(active_id=picking01.id, active_model='stock.picking')) + wizard = return_form.save() + wizard.product_return_moves.write({'quantity': 2.0}) + res = wizard.create_returns() + return01 = self.env['stock.picking'].browse(res['res_id']) + + return01.move_ids.quantity_done = 2 + return01.button_validate() + self.assertEqual(sale_order.order_line.qty_delivered, 0.0, "Delivered components: 0/4") + + picking02 = picking01.backorder_ids + picking02.move_ids.quantity_done = 1 + action = picking02.button_validate() + wizard = Form(self.env[action['res_model']].with_context(action['context'])).save() + wizard.process() + self.assertEqual(sale_order.order_line.qty_delivered, 0.0, "Delivered components: 1/4") + + picking03 = picking02.backorder_ids + picking03.move_ids.quantity_done = 1 + picking03.button_validate() + self.assertEqual(sale_order.order_line.qty_delivered, 0.0, "Delivered components: 2/4") + + # Create a return of return01 (with 1 component) + return_form = Form(self.env['stock.return.picking'].with_context(active_id=return01.id, active_model='stock.picking')) + wizard = return_form.save() + wizard.product_return_moves.write({'quantity': 1.0}) + res = wizard.create_returns() + picking04 = self.env['stock.picking'].browse(res['res_id']) + + picking04.move_ids.quantity_done = 1 + picking04.button_validate() + self.assertEqual(sale_order.order_line.qty_delivered, 0.0, "Delivered components: 3/4") + + # Create a second return of return01 (with 1 component, the last one) + return_form = Form(self.env['stock.return.picking'].with_context(active_id=return01.id, active_model='stock.picking')) + wizard = return_form.save() + wizard.product_return_moves.write({'quantity': 1.0}) + res = wizard.create_returns() + picking04 = self.env['stock.picking'].browse(res['res_id']) + + picking04.move_ids.quantity_done = 1 + picking04.button_validate() + self.assertEqual(sale_order.order_line.qty_delivered, 1, "Delivered components: 4/4") + + def test_cancelled_picking_and_delivered_qty(self): + """ + The delivered quantity should be zero if all SM are cancelled + """ + compo, kit = self.env['product.product'].create([{ + 'name': n, + 'type': 'consu', + 'route_ids': [(6, 0, [self.dropship_route.id])], + 'seller_ids': [(0, 0, {'partner_id': self.supplier.id})], + } for n in ['Compo', 'Kit']]) + + self.env['mrp.bom'].create({ + 'product_tmpl_id': kit.product_tmpl_id.id, + 'product_qty': 1, + 'type': 'phantom', + 'bom_line_ids': [ + (0, 0, {'product_id': compo.id, 'product_qty': 1}), + ], + }) + + sale_order = self.env['sale.order'].create({ + 'partner_id': self.customer.id, + 'picking_policy': 'direct', + 'order_line': [ + (0, 0, {'name': kit.name, 'product_id': kit.id, 'product_uom_qty': 1}), + ], + }) + sale_order.action_confirm() + self.env['purchase.order'].search([], order='id desc', limit=1).button_confirm() + self.assertEqual(sale_order.order_line.qty_delivered, 0.0) + + sale_order.picking_ids.action_cancel() + self.assertEqual(sale_order.order_line.qty_delivered, 0.0) diff --git a/addons/sale_mrp/models/sale_order_line.py b/addons/sale_mrp/models/sale_order_line.py index af82273ea73..dad7b7e4a83 100644 --- a/addons/sale_mrp/models/sale_order_line.py +++ b/addons/sale_mrp/models/sale_order_line.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, models +from odoo.tools import float_compare class SaleOrderLine(models.Model): @@ -45,18 +46,24 @@ class SaleOrderLine(models.Model): (b.product_id == order_line.product_id or (b.product_tmpl_id == order_line.product_id.product_tmpl_id and not b.product_id))) if relevant_bom: - # In case of dropship, we use a 'all or nothing' policy since 'bom_line_id' was - # not written on a move coming from a PO. + # not written on a move coming from a PO: all moves (to customer) must be done + # and the returns must be delivered back to the customer # FIXME: if the components of a kit have different suppliers, multiple PO # are generated. If one PO is confirmed and all the others are in draft, receiving # the products for this PO will set the qty_delivered. We might need to check the # state of all PO as well... but sale_mrp doesn't depend on purchase. if dropship: moves = order_line.move_ids.filtered(lambda m: m.state != 'cancel') - if moves and all(m.state == 'done' for m in moves): - order_line.qty_delivered = order_line.product_uom_qty + if any((m.location_dest_id.usage == 'customer' and m.state != 'done') + or (m.location_dest_id.usage != 'customer' + and m.state == 'done' + and float_compare(m.quantity_done, + sum(sub_m.product_uom._compute_quantity(sub_m.quantity_done, m.product_uom) for sub_m in m.returned_move_ids if sub_m.state == 'done'), + precision_rounding=m.product_uom.rounding) > 0) + for m in moves) or not moves: + order_line.qty_delivered = 0 else: - order_line.qty_delivered = 0.0 + order_line.qty_delivered = order_line.product_uom_qty continue moves = order_line.move_ids.filtered(lambda m: m.state == 'done' and not m.scrapped) filters = {