From 72adc1ba5b920deed1a3163c313eb2c0f7790778 Mon Sep 17 00:00:00 2001 From: Christopher Ormaza Date: Wed, 31 Jan 2024 15:32:36 -0500 Subject: [PATCH] [16.0][FIX] rma, add support to handle package used on customer moves --- rma/models/__init__.py | 1 + rma/models/rma_order_line.py | 4 + rma/models/stock_move.py | 19 ++ rma/models/stock_package_level.py | 20 +++ rma/tests/test_rma.py | 210 ++++++++++++++++++++++- rma/wizards/rma_make_picking.py | 81 +++++++-- rma_sale/models/rma_order_line.py | 31 ++++ rma_sale/tests/__init__.py | 1 + rma_sale/tests/test_rma_sale_tracking.py | 153 +++++++++++++++++ 9 files changed, 500 insertions(+), 20 deletions(-) create mode 100644 rma/models/stock_package_level.py create mode 100644 rma_sale/tests/test_rma_sale_tracking.py diff --git a/rma/models/__init__.py b/rma/models/__init__.py index a57381a19..52b7d7d4d 100644 --- a/rma/models/__init__.py +++ b/rma/models/__init__.py @@ -11,3 +11,4 @@ from . import res_partner from . import res_company from . import res_config_settings +from . import stock_package_level diff --git a/rma/models/rma_order_line.py b/rma/models/rma_order_line.py index eb66256d2..f8718116f 100644 --- a/rma/models/rma_order_line.py +++ b/rma/models/rma_order_line.py @@ -530,6 +530,10 @@ def _default_date_rma(self): string="Under Warranty?", readonly=True, states={"draft": [("readonly", False)]} ) + def _get_stock_move_reference(self): + self.ensure_one() + return self.reference_move_id + def _prepare_rma_line_from_stock_move(self, sm, lot=False): if not self.type: self.type = self._get_default_type() diff --git a/rma/models/stock_move.py b/rma/models/stock_move.py index 04e6b2054..4688af157 100644 --- a/rma/models/stock_move.py +++ b/rma/models/stock_move.py @@ -105,3 +105,22 @@ def _update_reserved_quantity( def _prepare_merge_moves_distinct_fields(self): res = super()._prepare_merge_moves_distinct_fields() return res + ["rma_line_id"] + + def _prepare_procurement_values(self): + self.ensure_one() + res = super(StockMove, self)._prepare_procurement_values() + res["rma_line_id"] = self.rma_line_id.id + return res + + +class StockMoveLine(models.Model): + + _inherit = "stock.move.line" + + def _should_bypass_reservation(self, location): + res = super(StockMoveLine, self)._should_bypass_reservation(location) + if self.env.context.get( + "force_no_bypass_reservation" + ) and location.usage not in ("customer", "supplier"): + return False + return res diff --git a/rma/models/stock_package_level.py b/rma/models/stock_package_level.py new file mode 100644 index 000000000..e8e14c04f --- /dev/null +++ b/rma/models/stock_package_level.py @@ -0,0 +1,20 @@ +from odoo import models + + +class StockPackageLevel(models.Model): + + _inherit = "stock.package_level" + + def write(self, values): + ctx = self.env.context.copy() + if ( + len(self) == 1 + and "location_dest_id" in values + and self.location_dest_id.id == values.get("location_dest_id") + ): + ctx.update( + { + "bypass_reservation_update": True, + } + ) + return super(StockPackageLevel, self.with_context(**ctx)).write(values) diff --git a/rma/tests/test_rma.py b/rma/tests/test_rma.py index 20c1caa46..0ae4a9020 100644 --- a/rma/tests/test_rma.py +++ b/rma/tests/test_rma.py @@ -16,6 +16,8 @@ def setUpClass(cls): cls.make_supplier_rma = cls.env["rma.order.line.make.supplier.rma"] cls.rma_add_stock_move = cls.env["rma_add_stock_move"] cls.product_ctg_model = cls.env["product.category"] + cls.lot_obj = cls.env["stock.lot"] + cls.package_obj = cls.env["stock.quant.package"] cls.stockpicking = cls.env["stock.picking"] cls.rma = cls.env["rma.order"] cls.rma_line = cls.env["rma.order.line"] @@ -31,6 +33,8 @@ def setUpClass(cls): ) cls.product_id = cls._create_product("PT0") cls.product_1 = cls._create_product("PT1") + cls.product_1_serial = cls._create_product("PT1 Serial", "serial") + cls.product_1_lot = cls._create_product("PT1 Lot", "lot") cls.product_2 = cls._create_product("PT2") cls.product_3 = cls._create_product("PT3") cls.uom_unit = cls.env.ref("uom.product_uom_unit") @@ -146,9 +150,14 @@ def _create_product_category( ) @classmethod - def _create_product(cls, name): + def _create_product(cls, name, tracking="none"): return cls.product_product_model.create( - {"name": name, "categ_id": cls.category.id, "type": "product"} + { + "name": name, + "categ_id": cls.category.id, + "type": "product", + "tracking": tracking, + } ) @classmethod @@ -174,7 +183,7 @@ def _do_picking(cls, picking): picking.button_validate() @classmethod - def _create_inventory(cls, product, qty, location): + def _create_inventory(cls, product, qty, location, lot_id=False, package_id=False): """ Creates inventory of a product on a specific location, this will be used eventually to create a inventory at specific cost, that will be received in @@ -187,6 +196,8 @@ def _create_inventory(cls, product, qty, location): "location_id": location.id, "product_id": product.id, "inventory_quantity": qty, + "lot_id": lot_id, + "package_id": package_id, } ) .action_apply_inventory() @@ -231,13 +242,21 @@ def _create_rma_from_move( for item in products2move: product = item[0] product_qty = item[1] - cls._create_inventory(product, product_qty, cls.stock_location) + lot_id = len(item) >= 3 and item[2] or False + origin_package_id = len(item) >= 4 and item[3] or False + destination_package_id = len(item) >= 5 and item[4] or False + cls._create_inventory( + product, product_qty, cls.stock_location, lot_id, origin_package_id + ) move_values = cls._prepare_move( product, product_qty, cls.stock_location, cls.customer_location, picking, + lot_id, + origin_package_id=origin_package_id, + destination_package_id=destination_package_id, ) moves.append(cls.env["stock.move"].create(move_values)) else: @@ -248,13 +267,21 @@ def _create_rma_from_move( for item in products2move: product = item[0] product_qty = item[1] - cls._create_inventory(product, product_qty, cls.stock_location) + lot_id = len(item) >= 3 and item[2] or False + origin_package_id = len(item) >= 4 and item[3] or False + destination_package_id = len(item) >= 5 and item[4] or False + cls._create_inventory( + product, product_qty, cls.stock_location, lot_id, origin_package_id + ) move_values = cls._prepare_move( product, product_qty, cls.supplier_location, cls.stock_rma_location, picking, + lot_id, + origin_package_id=origin_package_id, + destination_package_id=destination_package_id, ) moves.append(cls.env["stock.move"].create(move_values)) # Process the picking @@ -290,7 +317,9 @@ def _create_rma_from_move( data = ( wizard.with_user(cls.rma_basic_user) .with_context(customer=1) - ._prepare_rma_line_from_stock_move(move) + ._prepare_rma_line_from_stock_move( + move, lot=len(move.lot_ids) == 1 and move.lot_ids[0] or False + ) ) else: @@ -332,10 +361,20 @@ def _create_rma_from_move( return rma_id @classmethod - def _prepare_move(cls, product, qty, src, dest, picking_in): + def _prepare_move( + cls, + product, + qty, + src, + dest, + picking_in, + lot_id=False, + origin_package_id=False, + destination_package_id=False, + ): location_id = src.id - return { + res = { "name": product.name, "partner_id": picking_in.partner_id.id, "origin": picking_in.name, @@ -349,6 +388,29 @@ def _prepare_move(cls, product, qty, src, dest, picking_in): "picking_id": picking_in.id, "price_unit": product.standard_price, } + if lot_id or origin_package_id or destination_package_id: + res.update( + { + "move_line_ids": [ + ( + 0, + 0, + { + "picking_id": picking_in.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "qty_done": qty, + "lot_id": lot_id, + "package_id": origin_package_id, + "result_package_id": destination_package_id, + "location_id": location_id, + "location_dest_id": dest.id, + }, + ) + ] + } + ) + return res def _check_equal_quantity(self, qty1, qty2, msg): self.assertEqual(qty1, qty2, msg) @@ -1184,3 +1246,135 @@ def test_10_rma_cancel_line(self): self.assertEqual(second_rma_out_move_orig.state, "cancel") # check picking is not canceled because third line has not been yet. self.assertEqual(second_rma_out_move.picking_id.state, "waiting") + + def test_11_customer_rma_tracking_lot(self): + lot = self.lot_obj.create( + { + "product_id": self.product_1_lot.id, + } + ) + origin_package = self.package_obj.create({}) + destination_package = self.package_obj.create({}) + products2move = [ + (self.product_1_lot, 5, lot.id, origin_package.id, destination_package.id) + ] + rma_customer_id = self._create_rma_from_move( + products2move, + "customer", + self.env.ref("base.res_partner_2"), + dropship=False, + ) + rma = rma_customer_id.rma_line_ids + rma.action_rma_to_approve() + wizard = self.rma_make_picking.with_context( + **{ + "active_ids": rma.ids, + "active_model": "rma.order.line", + "picking_type": "incoming", + "active_id": rma.ids[0], + } + ).create({}) + wizard.action_create_picking() + res = rma.action_view_in_shipments() + self.assertTrue("res_id" in res, "Incorrect number of pickings" "created") + picking = self.env["stock.picking"].browse(res["res_id"]) + self.assertEqual(len(picking), 1, "Incorrect number of pickings created") + moves = picking.move_ids + self.assertEqual( + destination_package, + moves.mapped("move_line_ids.package_id"), + "Should have same package assigned", + ) + self.assertFalse( + bool(moves.mapped("move_line_ids.result_package_id")), + "Destination package should not be assigned", + ) + picking.action_assign() + for mv in picking.move_ids: + mv.quantity_done = mv.product_uom_qty + picking._action_done() + wizard = self.rma_make_picking.with_context( + **{ + "active_id": rma.ids[0], + "active_ids": rma.ids, + "active_model": "rma.order.line", + "picking_type": "outgoing", + } + ).create({}) + wizard.action_create_picking() + res = rma.action_view_out_shipments() + picking = self.env["stock.picking"].browse(res["res_id"]) + picking.action_assign() + for mv in picking.move_ids: + mv.quantity_done = mv.product_uom_qty + picking._action_done() + self.assertEqual(picking.state, "done", "Final picking should has done state") + + def test_12_customer_rma_tracking_serial(self): + lot = self.lot_obj.create( + { + "product_id": self.product_1_serial.id, + } + ) + origin_package = self.package_obj.create({}) + destination_package = self.package_obj.create({}) + products2move = [ + ( + self.product_1_serial, + 1, + lot.id, + origin_package.id, + destination_package.id, + ) + ] + rma_customer_id = self._create_rma_from_move( + products2move, + "customer", + self.env.ref("base.res_partner_2"), + dropship=False, + ) + rma = rma_customer_id.rma_line_ids + rma.action_rma_to_approve() + wizard = self.rma_make_picking.with_context( + **{ + "active_ids": rma.ids, + "active_model": "rma.order.line", + "picking_type": "incoming", + "active_id": rma.ids[0], + } + ).create({}) + wizard.action_create_picking() + res = rma.action_view_in_shipments() + self.assertTrue("res_id" in res, "Incorrect number of pickings" "created") + picking = self.env["stock.picking"].browse(res["res_id"]) + self.assertEqual(len(picking), 1, "Incorrect number of pickings created") + moves = picking.move_ids + self.assertEqual( + destination_package, + moves.mapped("move_line_ids.package_id"), + "Should have same package assigned", + ) + self.assertFalse( + bool(moves.mapped("move_line_ids.result_package_id")), + "Destination package should not be assigned", + ) + picking.action_assign() + for mv in picking.move_ids: + mv.quantity_done = mv.product_uom_qty + picking._action_done() + wizard = self.rma_make_picking.with_context( + **{ + "active_id": rma.ids[0], + "active_ids": rma.ids, + "active_model": "rma.order.line", + "picking_type": "outgoing", + } + ).create({}) + wizard.action_create_picking() + res = rma.action_view_out_shipments() + picking = self.env["stock.picking"].browse(res["res_id"]) + picking.action_assign() + for mv in picking.move_ids: + mv.quantity_done = mv.product_uom_qty + picking._action_done() + self.assertEqual(picking.state, "done", "Final picking should has done state") diff --git a/rma/wizards/rma_make_picking.py b/rma/wizards/rma_make_picking.py index 637caea9b..c0ea33f75 100644 --- a/rma/wizards/rma_make_picking.py +++ b/rma/wizards/rma_make_picking.py @@ -200,6 +200,21 @@ def _create_picking(self): procurements.extend(procurement) return procurements + def _is_final_step(self, move): + """This function helps to know if wizard is called to finish process of rma, + customer is delivery return, and supplier is receipt return""" + if ( + move.rma_line_id.type == "customer" + and self.env.context.get("picking_type") == "outgoing" + ): + return True + if ( + move.rma_line_id.type == "supplier" + and self.env.context.get("picking_type") == "incoming" + ): + return True + return False + def action_create_picking(self): self._create_picking() move_line_model = self.env["stock.move.line"] @@ -219,23 +234,68 @@ def action_create_picking(self): and x.rma_line_id.lot_id ): # Force the reservation of the RMA specific lot for incoming shipments. + is_final_step = self._is_final_step(move) move.move_line_ids.unlink() + reference_moves = ( + not is_final_step + and move.rma_line_id._get_stock_move_reference() + or self.env["stock.move"] + ) + package = reference_moves.mapped("move_line_ids.result_package_id") + quants = self.env["stock.quant"]._gather( + move.product_id, + move.location_id, + lot_id=move.rma_line_id.lot_id, + package_id=len(package) == 1 and package or False, + ) + move_line_data = move._prepare_move_line_vals( + reserved_quant=(len(quants) == 1) and quants or False + ) + move_line_data.update( + { + "qty_done": 0, + } + ) + if move.rma_line_id.lot_id and not quants: + # CHECK ME: force al least has lot assigned if quant is not found + move_line_data.update( + { + "lot_id": move.rma_line_id.lot_id.id, + } + ) if move.product_id.tracking == "serial": move.write( { - "lot_ids": [(6, 0, move.rma_line_id.lot_id.ids)], + "lot_ids": move.rma_line_id.lot_id.ids, } ) - quants = self.env["stock.quant"]._gather( - move.product_id, move.location_id, lot_id=move.rma_line_id.lot_id - ) - move.move_line_ids.write( + move_line_data.update( { - "reserved_uom_qty": 1 if picking_type == "incoming" else 0, - "qty_done": 0, - "package_id": len(quants) == 1 and quants.package_id.id, + "reserved_uom_qty": 1.0, } ) + if move.move_line_ids: + move.move_line_ids.with_context( + bypass_reservation_update=True + ).write( + { + "lot_id": move_line_data.get("lot_id"), + "package_id": move_line_data.get("package_id"), + "result_package_id": move_line_data.get( + "result_package_id", False + ), + "reserved_uom_qty": 1.0, + } + ) + if ( + len(quants) == 1 + and quants.reserved_quantity == 0 + and quants.quantity == 1 + and quants.location_id.usage not in ("customer", "supplier") + ): + quants.sudo().write( + {"reserved_quantity": quants.reserved_quantity + 1} + ) elif move.product_id.tracking == "lot": if picking_type == "incoming": qty = self.item_ids.filtered( @@ -245,15 +305,12 @@ def action_create_picking(self): qty = self.item_ids.filtered( lambda x: x.line_id.id == move.rma_line_id.id ).qty_to_deliver - move_line_data = move._prepare_move_line_vals() move_line_data.update( { - "lot_id": move.rma_line_id.lot_id.id, - "product_uom_id": move.product_id.uom_id.id, - "qty_done": 0, "reserved_uom_qty": qty if picking_type == "incoming" else 0, } ) + if not move.move_line_ids: move_line_model.create(move_line_data) pickings.with_context(force_no_bypass_reservation=True).action_assign() return action diff --git a/rma_sale/models/rma_order_line.py b/rma_sale/models/rma_order_line.py index 86c9aff46..7817a0d3b 100644 --- a/rma_sale/models/rma_order_line.py +++ b/rma_sale/models/rma_order_line.py @@ -81,6 +81,9 @@ def _compute_sales_count(self): readonly=False, ) sales_count = fields.Integer(compute="_compute_sales_count", string="# of Sales") + sale_line_domain_ids = fields.Many2many( + comodel_name="sale.order.line", compute="_compute_sale_line_domain" + ) @api.onchange("product_id", "partner_id") def _onchange_product_id(self): @@ -98,6 +101,34 @@ def _onchange_product_id(self): res["domain"]["sale_line_id"] = domain return res + def _get_stock_move_reference(self): + self.ensure_one() + move = self.reference_move_id + if self.sale_line_id: + # CHECK ME: backorder cases can be more than one move + sale_moves = self.sale_line_id.move_ids.filtered( + lambda x: x.location_dest_id.usage == "customer" and x.state == "done" + ) + if sale_moves: + return sale_moves + return move + + @api.depends("product_id", "partner_id") + def _compute_sale_line_domain(self): + line_model = self.env["sale.order.line"] + for rec in self: + domain = [] + if rec.partner_id: + domain = [ + "|", + ("order_id.partner_id", "=", rec.partner_id.id), + ("order_id.partner_id", "child_of", rec.partner_id.id), + ] + if rec.product_id: + domain.append(("product_id", "=", rec.product_id.id)) + lines = domain and line_model.search(domain) or line_model.browse() + rec.sale_line_domain_ids = lines.ids + @api.onchange("operation_id") def _onchange_operation_id(self): res = super(RmaOrderLine, self)._onchange_operation_id() diff --git a/rma_sale/tests/__init__.py b/rma_sale/tests/__init__.py index 2d622b802..13a4f1418 100644 --- a/rma_sale/tests/__init__.py +++ b/rma_sale/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_rma_sale from . import test_rma_stock_account_sale +from . import test_rma_sale_tracking diff --git a/rma_sale/tests/test_rma_sale_tracking.py b/rma_sale/tests/test_rma_sale_tracking.py new file mode 100644 index 000000000..b6c62788e --- /dev/null +++ b/rma_sale/tests/test_rma_sale_tracking.py @@ -0,0 +1,153 @@ +from odoo.addons.rma.tests.test_rma import TestRma + + +class TestRmaSaleTracking(TestRma): + @classmethod + def setUpClass(cls): + super(TestRmaSaleTracking, cls).setUpClass() + cls.rma_obj = cls.env["rma.order"] + cls.rma_line_obj = cls.env["rma.order.line"] + cls.rma_op_obj = cls.env["rma.operation"] + cls.rma_add_sale_wiz = cls.env["rma_add_sale"] + cls.rma_make_sale_wiz = cls.env["rma.order.line.make.sale.order"] + cls.so_obj = cls.env["sale.order"] + cls.sol_obj = cls.env["sale.order.line"] + cls.product_obj = cls.env["product.product"] + cls.partner_obj = cls.env["res.partner"] + cls.rma_route_cust = cls.env.ref("rma.route_rma_customer") + cls.customer1 = cls.partner_obj.create({"name": "Customer 1"}) + cls.product_lot_2 = cls._create_product("PT2 Lot", "lot") + cls.product_serial_2 = cls._create_product("PT2 Serial", "serial") + + cls.so = cls.so_obj.create( + { + "partner_id": cls.customer1.id, + "partner_invoice_id": cls.customer1.id, + "partner_shipping_id": cls.customer1.id, + "order_line": [ + ( + 0, + 0, + { + "name": cls.product_serial_2.name, + "product_id": cls.product_serial_2.id, + "product_uom_qty": 1.0, + "product_uom": cls.product_serial_2.uom_id.id, + "price_unit": cls.product_serial_2.list_price, + }, + ), + ( + 0, + 0, + { + "name": cls.product_lot_2.name, + "product_id": cls.product_lot_2.id, + "product_uom_qty": 18.0, + "product_uom": cls.product_lot_2.uom_id.id, + "price_unit": cls.product_lot_2.list_price, + }, + ), + ], + "pricelist_id": cls.env.ref("product.list0").id, + } + ) + cls.so.action_confirm() + + cls.serial = cls.lot_obj.create( + { + "product_id": cls.product_serial_2.id, + } + ) + cls.lot = cls.lot_obj.create( + { + "product_id": cls.product_lot_2.id, + } + ) + + cls.package_1 = cls.package_obj.create({}) + cls.package_2 = cls.package_obj.create({}) + cls.package_3 = cls.package_obj.create({}) + cls.package_4 = cls.package_obj.create({}) + + cls._create_inventory( + cls.product_serial_2, 1, cls.stock_location, cls.serial.id, cls.package_1.id + ) + + cls._create_inventory( + cls.product_lot_2, 1, cls.stock_location, cls.lot.id, cls.package_3.id + ) + + picking = cls.so.picking_ids + + picking.action_assign() + + for move in picking.move_lines: + if move.product_id.id == cls.product_serial_2.id: + move.move_line_ids.write({"result_package_id": cls.package_2.id}) + if move.product_id.id == cls.product_lot_2.id: + move.move_line_ids.write({"result_package_id": cls.package_4.id}) + + cls._do_picking(picking) + + # Create RMA group and operation: + cls.rma_group = cls.rma_obj.create({"partner_id": cls.customer1.id}) + cls.operation_1 = cls.rma_op_obj.create( + { + "code": "TEST", + "name": "Sale afer receive", + "type": "customer", + "receipt_policy": "ordered", + "sale_policy": "received", + "in_route_id": cls.rma_route_cust.id, + "out_route_id": cls.rma_route_cust.id, + } + ) + + add_sale = cls.rma_add_sale_wiz.with_context( + **{ + "customer": True, + "active_ids": cls.rma_group.id, + "active_model": "rma.order", + } + ).create( + {"sale_id": cls.so.id, "sale_line_ids": [(6, 0, cls.so.order_line.ids)]} + ) + add_sale.add_lines() + + def test_01_customer_rma_tracking(self): + rma_serial = self.rma_group.rma_line_ids.filtered( + lambda r: r.product_id == self.product_serial_2 + ) + rma_lot = self.rma_group.rma_line_ids.filtered( + lambda r: r.product_id == self.product_lot_2 + ) + for rma in rma_serial + rma_lot: + wizard = self.rma_make_picking.with_context( + **{ + "active_ids": rma.ids, + "active_model": "rma.order.line", + "picking_type": "incoming", + "active_id": rma.ids[0], + } + ).create({}) + wizard.action_create_picking() + res = rma.action_view_in_shipments() + self.assertTrue("res_id" in res, "Incorrect number of pickings" "created") + picking = self.env["stock.picking"].browse(res["res_id"]) + self.assertEqual(len(picking), 1, "Incorrect number of pickings created") + moves = picking.move_lines + self.asserTrue( + bool(moves.mapped("move_line_ids.package_id")), + "Should have same package assigned", + ) + self.assertFalse( + bool(moves.mapped("move_line_ids.result_package_id")), + "Destination package should not be assigned", + ) + picking.action_assign() + for mv in picking.move_lines: + mv.quantity_done = mv.product_uom_qty + picking._action_done() + self.assertEqual( + picking.state, "done", "Final picking should has done state" + )