From 04c35d166c1b7210914df0c485c910633fc92000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Fri, 28 Jun 2024 12:53:28 +0200 Subject: [PATCH 1/2] [FIX] rma: Add location_id field for the expected behavior The location_id field is important and its value is required, we need to add it to the wizard (even if it is hidden) so that it is saved and the picking is created correctly. Before, the location_id value was only saved (although the field was not in the wizard) if the user had the "Technical / Manage Multiple Stock Locations" permission. --- rma/wizard/stock_picking_return_views.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/rma/wizard/stock_picking_return_views.xml b/rma/wizard/stock_picking_return_views.xml index 271f83c9..983815eb 100644 --- a/rma/wizard/stock_picking_return_views.xml +++ b/rma/wizard/stock_picking_return_views.xml @@ -17,6 +17,7 @@ + From ad115d1b277687de941c9c88259eb9721b307197 Mon Sep 17 00:00:00 2001 From: Michael Tietz Date: Tue, 16 Apr 2024 00:08:46 +0200 Subject: [PATCH 2/2] [IMP] rma: use only procurement.group run to create stock transfers Extra changes: - Change reception_move_ids to reception_move_id - Add test_rma_replace_pick_ship - Code and method reduction to simplify logic - Set route_ids (in/out) from procurements Co-authored-by: Michael Tietz TT48789 --- rma/README.rst | 5 + rma/__manifest__.py | 2 +- rma/hooks.py | 13 +- rma/migrations/17.0.1.2.0/post-migration.py | 17 + rma/models/res_partner.py | 2 +- rma/models/rma.py | 517 +++++++++----------- rma/models/stock_move.py | 49 +- rma/models/stock_warehouse.py | 125 ++++- rma/readme/CONTRIBUTORS.md | 1 + rma/readme/ROADMAP.md | 3 + rma/static/description/index.html | 4 + rma/tests/test_rma.py | 99 +++- rma/wizard/stock_picking_return.py | 103 +++- 13 files changed, 570 insertions(+), 370 deletions(-) create mode 100644 rma/migrations/17.0.1.2.0/post-migration.py diff --git a/rma/README.rst b/rma/README.rst index ac962781..148de528 100644 --- a/rma/README.rst +++ b/rma/README.rst @@ -140,6 +140,9 @@ Known issues / Roadmap - As soon as the picking is selected, the user should select the move, but perhaps stock.move \_rec_name could be improved to better show what the product of that move is. +- Add RMA reception and/or RMA delivery on several steps - 2 or 3 - + like normal receptions/deliveries. It should be a separate option + inside the warehouse definition. Bug Tracker =========== @@ -175,6 +178,8 @@ Contributors - Antoni Marroig +- Michael Tietz (MT Software) mtietz@mt-software.de + Maintainers ----------- diff --git a/rma/__manifest__.py b/rma/__manifest__.py index 044f3d6f..59ec10f6 100644 --- a/rma/__manifest__.py +++ b/rma/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Return Merchandise Authorization Management", "summary": "Return Merchandise Authorization (RMA)", - "version": "17.0.1.1.1", + "version": "17.0.1.2.0", "development_status": "Production/Stable", "category": "RMA", "website": "https://github.com/OCA/rma", diff --git a/rma/hooks.py b/rma/hooks.py index 5b46f9aa..f9995950 100644 --- a/rma/hooks.py +++ b/rma/hooks.py @@ -1,4 +1,5 @@ # Copyright 2020 Tecnativa - Ernesto Tejeda +# Copyright 2023 Michael Tietz (MT Software) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). @@ -20,7 +21,9 @@ def post_init_hook(env): def create_rma_locations(warehouse): stock_location = env["stock.location"] if not warehouse.rma_loc_id: - rma_location_vals = warehouse._get_rma_location_values() + rma_location_vals = warehouse._get_rma_location_values( + {"company_id": warehouse.company_id.id}, warehouse.code + ) warehouse.rma_loc_id = ( stock_location.with_context(active_test=False) .create(rma_location_vals) @@ -57,11 +60,19 @@ def post_init_hook(env): whs.rma_in_type_id.return_picking_type_id = whs.rma_out_type_id.id whs.rma_out_type_id.return_picking_type_id = whs.rma_in_type_id.id + def create_rma_routes(warehouses): + """Create initially rma in/out stock.location.routes and stock.rules""" + warehouses = warehouses.with_context(rma_post_init_hook=True) + for wh in warehouses: + route_vals = wh._create_or_update_route() + wh.write(route_vals) + # Create rma locations and picking types warehouses = env["stock.warehouse"].search([]) for warehouse in warehouses: create_rma_locations(warehouse) create_rma_picking_types(warehouse) + create_rma_routes(warehouses) # Create rma sequence per company for company in env["res.company"].search([]): company.create_rma_index() diff --git a/rma/migrations/17.0.1.2.0/post-migration.py b/rma/migrations/17.0.1.2.0/post-migration.py new file mode 100644 index 00000000..71132ec7 --- /dev/null +++ b/rma/migrations/17.0.1.2.0/post-migration.py @@ -0,0 +1,17 @@ +# Copyright 2024 Tecnativa - Víctor Martínez +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + """Similar behavior to create_rma_routes of post_init_hook.""" + warehouses = env["stock.warehouse"].search([]) + warehouses = warehouses.with_context(rma_post_init_hook=True) + for wh in warehouses: + if not wh.rma_in_type_id or not wh.rma_out_type_id: + data = wh._create_or_update_sequences_and_picking_types() + wh.write(data) + route_vals = wh._create_or_update_route() + wh.write(route_vals) diff --git a/rma/models/res_partner.py b/rma/models/res_partner.py index e300f6b8..d96a2dda 100644 --- a/rma/models/res_partner.py +++ b/rma/models/res_partner.py @@ -27,7 +27,7 @@ class ResPartner(models.Model): def action_view_rma(self): self.ensure_one() - action = self.sudo().env.ref("rma.rma_action").read()[0] + action = self.env["ir.actions.act_window"]._for_xml_id("rma.rma_action") rma = self.rma_ids if len(rma) == 1: action.update( diff --git a/rma/models/rma.py b/rma/models/rma.py index 57045089..124f2c2a 100644 --- a/rma/models/rma.py +++ b/rma/models/rma.py @@ -1,12 +1,13 @@ # Copyright 2020 Tecnativa - Ernesto Tejeda # Copyright 2023 Tecnativa - Pedro M. Baeza +# Copyright 2023 Michael Tietz (MT Software) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). import logging -from collections import Counter +from collections import defaultdict +from itertools import groupby from odoo import _, api, fields, models from odoo.exceptions import AccessError, ValidationError -from odoo.tests import Form from odoo.tools import html2plaintext from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES @@ -259,18 +260,8 @@ class Rma(models.Model): ) def _compute_delivery_picking_count(self): - # It is enough to count the moves to know how many pickings - # there are because there will be a unique move linked to the - # same picking and the same rma. - rma_data = self.env["stock.move"].read_group( - [("rma_id", "in", self.ids)], - ["rma_id", "picking_id"], - ["rma_id", "picking_id"], - lazy=False, - ) - mapped_data = Counter(map(lambda r: r["rma_id"][0], rma_data)) - for record in self: - record.delivery_picking_count = mapped_data.get(record.id, 0) + for rma in self: + rma.delivery_picking_count = len(rma.delivery_move_ids.picking_id) @api.depends( "delivery_move_ids", @@ -609,22 +600,85 @@ class Rma(models.Model): } def _add_message_subscribe_partner(self): + self.ensure_one() if self.partner_id and self.partner_id not in self.message_partner_ids: self.message_subscribe([self.partner_id.id]) + def _product_is_storable(self, product=None): + product = product or self.product_id + return product.type in ["product", "consu"] + + def _prepare_procurement_group_vals(self): + return { + "move_type": "direct", + "partner_id": self and self.partner_shipping_id.id or False, + "name": self and ", ".join(self.mapped("name")) or False, + } + + def _prepare_common_procurement_vals( + self, warehouse=None, scheduled_date=None, group=None + ): + self.ensure_one() + group = group or self.procurement_group_id + if not group: + group = self.env["procurement.group"].create( + self._prepare_procurement_group_vals() + ) + return { + "company_id": self.company_id, + "group_id": group, + "date_planned": scheduled_date or fields.Datetime.now(), + "warehouse_id": warehouse or self.warehouse_id, + "partner_id": group.partner_id.id, + "priority": self.priority, + } + + def _prepare_reception_procurement_vals(self, group=None): + """This method is used only for reception and a specific RMA IN route.""" + vals = self._prepare_common_procurement_vals(group=group) + vals["route_ids"] = self.warehouse_id.rma_in_route_id + vals["rma_receiver_ids"] = [(6, 0, self.ids)] + if self.move_id: + vals["origin_returned_move_id"] = self.move_id.id + return vals + + def _prepare_reception_procurements(self): + procurements = [] + group_model = self.env["procurement.group"] + for rma in self: + if not rma._product_is_storable(): + continue + group = rma.procurement_group_id + if not group: + group = group_model.create(rma._prepare_procurement_group_vals()) + procurements.append( + group_model.Procurement( + rma.product_id, + rma.product_uom_qty, + rma.product_uom, + rma.location_id, + rma.product_id.display_name, + group.name, + rma.company_id, + rma._prepare_reception_procurement_vals(group), + ) + ) + return procurements + def action_confirm(self): """Invoked when 'Confirm' button in rma form view is clicked.""" - self.ensure_one() self._ensure_required_fields() - if self.state == "draft": - if self.picking_id: - reception_move = self._create_receptions_from_picking() - else: - reception_move = self._create_receptions_from_product() - reception_move.picked = True - self.write({"reception_move_id": reception_move.id, "state": "confirmed"}) - self._add_message_subscribe_partner() - self._send_confirmation_email() + self = self.filtered(lambda rma: rma.state == "draft") + if not self: + return + procurements = self._prepare_reception_procurements() + if procurements: + self.env["procurement.group"].run(procurements) + self.reception_move_id.picking_id.action_assign() + self.write({"state": "confirmed"}) + for rma in self: + rma._add_message_subscribe_partner() + self._send_confirmation_email() def action_refund(self): """Invoked when 'Refund' button in rma form view is clicked @@ -663,11 +717,8 @@ class Rma(models.Model): self._ensure_can_be_replaced() # Force active_id to avoid issues when coming from smart buttons # in other models - action = ( - self.env.ref("rma.rma_delivery_wizard_action") - .sudo() - .with_context(active_id=self.id) - .read()[0] + action = self.env["ir.actions.act_window"]._for_xml_id( + "rma.rma_delivery_wizard_action" ) action["name"] = "Replace product(s)" action["context"] = dict(self.env.context) @@ -686,11 +737,8 @@ class Rma(models.Model): self._ensure_can_be_returned() # Force active_id to avoid issues when coming from smart buttons # in other models - action = ( - self.env.ref("rma.rma_delivery_wizard_action") - .sudo() - .with_context(active_id=self.id) - .read()[0] + action = self.env["ir.actions.act_window"]._for_xml_id( + "rma.rma_delivery_wizard_action" ) action["context"] = dict(self.env.context) action["context"].update( @@ -706,11 +754,8 @@ class Rma(models.Model): self._ensure_can_be_split() # Force active_id to avoid issues when coming from smart buttons # in other models - action = ( - self.env.ref("rma.rma_split_wizard_action") - .sudo() - .with_context(active_id=self.id) - .read()[0] + action = self.env["ir.actions.act_window"]._for_xml_id( + "rma.rma_split_wizard_action" ) action["context"] = dict(self.env.context) action["context"].update(active_id=self.id, active_ids=self.ids) @@ -722,11 +767,8 @@ class Rma(models.Model): self._ensure_can_be_returned() # Force active_id to avoid issues when coming from smart buttons # in other models - action = ( - self.env.ref("rma.rma_finalization_wizard_action") - .sudo() - .with_context(active_id=self.id) - .read()[0] + action = self.env["ir.actions.act_window"]._for_xml_id( + "rma.rma_finalization_wizard_action" ) action["context"] = dict(self.env.context) action["context"].update(active_id=self.id, active_ids=self.ids) @@ -734,7 +776,7 @@ class Rma(models.Model): def action_cancel(self): """Invoked when 'Cancel' button in rma form view is clicked.""" - self.mapped("reception_move_id")._action_cancel() + self.reception_move_id._action_cancel() self.write({"state": "cancelled"}) def action_draft(self): @@ -759,25 +801,28 @@ class Rma(models.Model): "url": self.get_portal_url(), } - def action_view_receipt(self): - """Invoked when 'Receipt' smart button in rma form view is clicked.""" + def _action_view_pickings(self, pickings): self.ensure_one() # Force active_id to avoid issues when coming from smart buttons # in other models - action = ( - self.env.ref("stock.action_picking_tree_all") - .sudo() - .with_context(active_id=self.id) - .read()[0] - ) - action.update( - res_id=self.reception_move_id.picking_id.id, - view_mode="form", - view_id=False, - views=False, + action = self.env["ir.actions.act_window"]._for_xml_id( + "stock.action_picking_tree_all" ) + if len(pickings) > 1: + action["domain"] = [("id", "in", pickings.ids)] + elif pickings: + action.update( + res_id=pickings.id, + view_mode="form", + view_id=False, + views=False, + ) return action + def action_view_receipt(self): + """Invoked when 'Receipt' smart button in rma form view is clicked.""" + return self._action_view_pickings(self.mapped("reception_move_id.picking_id")) + def action_view_refund(self): """Invoked when 'Refund' smart button in rma form view is clicked.""" self.ensure_one() @@ -793,23 +838,7 @@ class Rma(models.Model): def action_view_delivery(self): """Invoked when 'Delivery' smart button in rma form view is clicked.""" - action = ( - self.env.ref("stock.action_picking_tree_all") - .sudo() - .with_context(active_id=self.id) - .read()[0] - ) - picking = self.delivery_move_ids.mapped("picking_id") - if len(picking) > 1: - action["domain"] = [("id", "in", picking.ids)] - elif picking: - action.update( - res_id=picking.id, - view_mode="form", - view_id=False, - views=False, - ) - return action + return self._action_view_pickings(self.mapped("delivery_move_ids.picking_id")) # Validation business methods def _ensure_required_fields(self): @@ -925,83 +954,6 @@ class Rma(models.Model): ) ) - # Reception business methods - def _create_receptions_from_picking(self): - self.ensure_one() - stock_return_picking_form = Form( - self.env["stock.return.picking"].with_context( - active_ids=self.picking_id.ids, - active_id=self.picking_id.id, - active_model="stock.picking", - ) - ) - return_wizard = stock_return_picking_form.save() - if self.location_id: - return_wizard.location_id = self.location_id - return_wizard.product_return_moves.filtered( - lambda r: r.move_id != self.move_id - ).unlink() - return_line = return_wizard.product_return_moves - return_line.update( - { - "quantity": self.product_uom_qty, - # The to_refund field is now True by default, which isn't right in the - # RMA creation context - "to_refund": False, - } - ) - # set_rma_picking_type is to override the copy() method of stock - # picking and change the default picking type to rma picking type. - picking_action = return_wizard.with_context( - set_rma_picking_type=True - ).create_returns() - picking_id = picking_action["res_id"] - picking = self.env["stock.picking"].browse(picking_id) - picking.origin = f"{self.name} ({picking.origin})" - move = picking.move_ids - move.priority = self.priority - return move - - def _create_receptions_from_product(self): - self.ensure_one() - picking = self.env["stock.picking"].create(self._prepare_picking_vals()) - picking.action_confirm() - picking.action_assign() - picking.message_post_with_source( - "mail.message_origin_link", - render_values={"self": picking, "origin": self}, - subtype_id=self.env["ir.model.data"]._xmlid_to_res_id("mail.mt_note"), - ) - return picking.move_ids - - def _prepare_picking_vals(self): - return { - "picking_type_id": self.warehouse_id.rma_in_type_id.id, - "origin": self.name, - "partner_id": self.partner_shipping_id.id, - "location_id": self.partner_shipping_id.property_stock_customer.id, - "location_dest_id": self.location_id.id, - "move_ids": [ - ( - 0, - 0, - { - "product_id": self.product_id.id, - # same text as origin move or product text in partner lang - "name": self.move_id.name - or self.product_id.with_context( - lang=self.partner_id.lang or "en_US" - ).display_name, - "location_id": ( - self.partner_shipping_id.property_stock_customer.id - ), - "location_dest_id": self.location_id.id, - "product_uom_qty": self.product_uom_qty, - }, - ) - ], - } - # Extract business methods def extract_quantity(self, qty, uom): self.ensure_one() @@ -1075,44 +1027,101 @@ class Rma(models.Model): "rma_id": self.id, } - # Returning business methods - def create_return(self, scheduled_date, qty=None, uom=None): - """Intended to be invoked by the delivery wizard""" + def _delivery_should_be_grouped(self): + """Checks if the rmas should be grouped for the delivery process""" group_returns = self.env.company.rma_return_grouping if "rma_return_grouping" in self.env.context: group_returns = self.env.context.get("rma_return_grouping") + return group_returns + + def _delivery_group_key(self): + """Returns a key by which the rmas should be grouped for the delivery process""" + self.ensure_one() + return (self.partner_shipping_id.id, self.company_id.id, self.warehouse_id.id) + + def _group_delivery_if_needed(self): + """Groups the given rmas by the returned key from _delivery_group_key + by setting the procurement_group_id on the each rma if there is not yet on + set""" + if not self._delivery_should_be_grouped(): + return + grouped_rmas = groupby( + sorted(self, key=lambda rma: rma._delivery_group_key()), + key=lambda rma: [rma._delivery_group_key()], + ) + for _group, rmas in grouped_rmas: + rmas = ( + self.browse() + .concat(*list(rmas)) + .filtered(lambda rma: not rma.procurement_group_id) + ) + if not rmas: + continue + proc_group = self.env["procurement.group"].create( + rmas._prepare_procurement_group_vals() + ) + rmas.write({"procurement_group_id": proc_group.id}) + + def _prepare_delivery_procurement_vals(self, scheduled_date=None): + """This method is used only for Delivery (not replace). It is important to set + RMA Out route.""" + vals = self._prepare_common_procurement_vals(scheduled_date=scheduled_date) + vals["rma_id"] = self.id + vals["route_ids"] = self.warehouse_id.rma_out_route_id + vals["move_orig_ids"] = [(6, 0, self.reception_move_id.ids)] + return vals + + def _prepare_delivery_procurements(self, scheduled_date=None, qty=None, uom=None): + self._group_delivery_if_needed() + procurements = [] + group_model = self.env["procurement.group"] + for rma in self: + if not rma.procurement_group_id: + rma.procurement_group_id = group_model.create( + rma._prepare_procurement_group_vals() + ) + + vals = rma._prepare_delivery_procurement_vals(scheduled_date) + group = vals.get("group_id") + procurements.append( + group_model.Procurement( + rma.product_id, + qty or rma.product_uom_qty, + uom or rma.product_uom, + rma.partner_shipping_id.property_stock_customer, + rma.product_id.display_name, + group.name, + rma.company_id, + vals, + ) + ) + return procurements + + # Returning business methods + def create_return(self, scheduled_date, qty=None, uom=None): + """Intended to be invoked by the delivery wizard""" self._ensure_can_be_returned() self._ensure_qty_to_return(qty, uom) - group_dict = {} - rmas_to_return = self.filtered("can_be_returned") - for record in rmas_to_return: - key = ( - record.partner_shipping_id.id, - record.company_id.id, - record.warehouse_id, + rmas_to_return = self.filtered( + lambda rma: rma.can_be_returned and rma._product_is_storable() + ) + procurements = rmas_to_return._prepare_delivery_procurements( + scheduled_date, qty, uom + ) + if procurements: + self.env["procurement.group"].run(procurements) + pickings = defaultdict(lambda: self.browse()) + for rma in rmas_to_return: + picking = rma.delivery_move_ids.picking_id.sorted("id", reverse=True)[0] + pickings[picking] |= rma + rma.message_post( + body=_( + 'Return: %(name)s has been created.' + ) + % ({"id": picking.id, "name": picking.name}) ) - group_dict.setdefault(key, self.env["rma"]) - group_dict[key] |= record - if group_returns: - grouped_rmas = group_dict.values() - else: - grouped_rmas = rmas_to_return - for rmas in grouped_rmas: - origin = ", ".join(rmas.mapped("name")) - picking_vals = rmas[0]._prepare_returning_picking_vals(origin) - for rma in rmas: - picking_vals["move_ids"].append( - (0, 0, rma._prepare_returning_move_vals(scheduled_date, qty, uom)) - ) - picking = self.env["stock.picking"].create(picking_vals) - for rma in rmas: - rma.message_post( - body=_( - 'Return: %(name)s has been created.' - ) - % ({"id": picking.id, "name": picking.name}) - ) + for picking, rmas in pickings.items(): picking.action_confirm() picking.action_assign() picking.message_post_with_source( @@ -1122,42 +1131,53 @@ class Rma(models.Model): ) rmas_to_return.write({"state": "waiting_return"}) - def _prepare_returning_picking_vals(self, origin=None): - self.ensure_one() - return { - "picking_type_id": self.warehouse_id.rma_out_type_id.id, - "location_id": self.location_id.id, - "location_dest_id": self.reception_move_id.location_id.id, - "origin": origin or self.name, - "partner_id": self.partner_shipping_id.id, - "company_id": self.company_id.id, - "move_ids": [], - } + def _prepare_replace_procurement_vals(self, warehouse=None, scheduled_date=None): + """This method is used only for Replace (not Delivery). We do not use any + specific route here.""" + vals = self._prepare_common_procurement_vals(warehouse, scheduled_date) + vals["rma_id"] = self.id + return vals - def _prepare_returning_move_vals(self, scheduled_date, quantity=None, uom=None): - self.ensure_one() - return { - "product_id": self.product_id.id, - "name": self.product_id.with_context( - lang=self.partner_shipping_id.lang or "en_US" - ).display_name, - "product_uom_qty": quantity or self.product_uom_qty, - "product_uom": uom and uom.id or self.product_uom.id, - "location_id": self.location_id.id, - "location_dest_id": self.reception_move_id.location_id.id, - "date": scheduled_date, - "rma_id": self.id, - "move_orig_ids": [(4, self.reception_move_id.id)], - "company_id": self.company_id.id, - } + def _prepare_replace_procurements( + self, warehouse, scheduled_date, product, qty, uom + ): + procurements = [] + group_model = self.env["procurement.group"] + for rma in self: + if not rma._product_is_storable(product): + continue + + if not rma.procurement_group_id: + rma.procurement_group_id = group_model.create( + rma._prepare_procurement_group_vals() + ) + + vals = rma._prepare_replace_procurement_vals(warehouse, scheduled_date) + group = vals.get("group_id") + procurements.append( + group_model.Procurement( + product, + qty, + uom, + rma.partner_shipping_id.property_stock_customer, + product.display_name, + group.name, + rma.company_id, + vals, + ) + ) + return procurements # Replacing business methods def create_replace(self, scheduled_date, warehouse, product, qty, uom): """Intended to be invoked by the delivery wizard""" - self.ensure_one() self._ensure_can_be_replaced() moves_before = self.delivery_move_ids - self._action_launch_stock_rule(scheduled_date, warehouse, product, qty, uom) + procurements = self._prepare_replace_procurements( + warehouse, scheduled_date, product, qty, uom + ) + if procurements: + self.env["procurement.group"].run(procurements) new_moves = self.delivery_move_ids - moves_before body = "" # The product replacement could explode into several moves like in the case of @@ -1181,6 +1201,12 @@ class Rma(models.Model): ) + "\n" ) + for rma in self: + rma._add_replace_message(body, qty, uom) + self.write({"state": "waiting_replacement"}) + + def _add_replace_message(self, body, qty, uom): + self.ensure_one() self.message_post( body=body or _( @@ -1193,74 +1219,13 @@ class Rma(models.Model): ) % ( { - "id": product.id, - "name": product.display_name, + "id": self.id, + "name": self.display_name, "qty": qty, "uom": uom.name, } ) ) - if self.state != "waiting_replacement": - self.state = "waiting_replacement" - - def _action_launch_stock_rule( - self, - scheduled_date, - warehouse, - product, - qty, - uom, - ): - """Creates a delivery picking and launch stock rule. It is invoked by: - rma.create_replace - """ - self.ensure_one() - if self.product_id.type not in ("consu", "product"): - return - if not self.procurement_group_id: - self.procurement_group_id = ( - self.env["procurement.group"] - .create( - { - "name": self.name, - "move_type": "direct", - "partner_id": self.partner_shipping_id.id, - } - ) - .id - ) - values = self._prepare_procurement_values( - self.procurement_group_id, scheduled_date, warehouse - ) - procurement = self.env["procurement.group"].Procurement( - product, - qty, - uom, - self.partner_shipping_id.property_stock_customer, - self.product_id.display_name, - self.procurement_group_id.name, - self.company_id, - values, - ) - self.env["procurement.group"].run([procurement]) - return True - - def _prepare_procurement_values( - self, - group_id, - scheduled_date, - warehouse, - ): - self.ensure_one() - return { - "company_id": self.company_id, - "group_id": group_id, - "date_planned": scheduled_date, - "warehouse_id": warehouse, - "partner_id": self.partner_shipping_id.id, - "rma_id": self.id, - "priority": self.priority, - } # Mail business methods def _creation_subtype(self): diff --git a/rma/models/stock_move.py b/rma/models/stock_move.py index 56579b49..40329279 100644 --- a/rma/models/stock_move.py +++ b/rma/models/stock_move.py @@ -1,4 +1,5 @@ # Copyright 2020 Tecnativa - Ernesto Tejeda +# Copyright 2023 Michael Tietz (MT Software) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo import _, api, fields, models @@ -23,7 +24,7 @@ class StockMove(models.Model): string="RMA receivers", copy=False, ) - # RMA that create the delivery movement to the customer + # RMA that creates the out move rma_id = fields.Many2one( comodel_name="rma", string="RMA return", @@ -33,8 +34,8 @@ class StockMove(models.Model): def unlink(self): # A stock user could have no RMA permissions, so the ids wouldn't # be accessible due to record rules. - rma_receiver = self.sudo().mapped("rma_receiver_ids") - rma = self.sudo().mapped("rma_id") + rma_receiver = self.sudo().rma_receiver_ids + rma = self.sudo().rma_id res = super().unlink() rma_receiver.filtered(lambda x: x.state != "cancelled").write( {"state": "draft"} @@ -115,38 +116,22 @@ class StockMove(models.Model): res["rma_id"] = self.sudo().rma_id.id return res - def _prepare_return_rma_vals(self, original_picking): - """hook method for preparing an RMA from the 'return picking wizard'.""" - self.ensure_one() - partner = original_picking.partner_id - if hasattr(original_picking, "sale_id") and original_picking.sale_id: - partner_invoice_id = original_picking.sale_id.partner_invoice_id.id - partner_shipping_id = original_picking.sale_id.partner_shipping_id.id - else: - partner_invoice_id = partner.address_get(["invoice"]).get("invoice", False) - partner_shipping_id = partner.address_get(["delivery"]).get( - "delivery", False - ) - return { - "user_id": self.env.user.id, - "partner_id": partner.id, - "partner_shipping_id": partner_shipping_id, - "partner_invoice_id": partner_invoice_id, - "origin": original_picking.name, - "picking_id": original_picking.id, - "move_id": self.origin_returned_move_id.id, - "product_id": self.origin_returned_move_id.product_id.id, - "product_uom_qty": self.product_uom_qty, - "product_uom": self.product_uom.id, - "reception_move_id": self.id, - "company_id": self.company_id.id, - "location_id": self.location_dest_id.id, - "state": "confirmed", - } + def _prepare_procurement_values(self): + res = super()._prepare_procurement_values() + if self.rma_id: + res["rma_id"] = self.rma_id.id + return res class StockRule(models.Model): _inherit = "stock.rule" def _get_custom_move_fields(self): - return super()._get_custom_move_fields() + ["rma_id"] + move_fields = super()._get_custom_move_fields() + move_fields += [ + "rma_id", + "origin_returned_move_id", + "move_orig_ids", + "rma_receiver_ids", + ] + return move_fields diff --git a/rma/models/stock_warehouse.py b/rma/models/stock_warehouse.py index f3759274..79ad9dbf 100644 --- a/rma/models/stock_warehouse.py +++ b/rma/models/stock_warehouse.py @@ -1,7 +1,8 @@ # Copyright 2020 Tecnativa - Ernesto Tejeda +# Copyright 2023 Michael Tietz (MT Software) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models +from odoo import _, fields, models class StockWarehouse(models.Model): @@ -27,35 +28,39 @@ class StockWarehouse(models.Model): comodel_name="stock.location", string="RMA Location", ) + rma_in_route_id = fields.Many2one("stock.route", "RMA in Route") + rma_out_route_id = fields.Many2one("stock.route", "RMA out Route") - @api.model_create_multi - def create(self, vals_list): - """To create an RMA location and link it with a new warehouse, - this method is overridden instead of '_get_locations_values' - method because the locations that are created with the - values ​​returned by that method are forced to be children - of view_location_id, and we don't want that. - """ - res = super().create(vals_list) - stock_location = self.env["stock.location"] - for record in res: - rma_location_vals = record._get_rma_location_values() - record.rma_loc_id = stock_location.create(rma_location_vals).id - return res - - def _get_rma_location_values(self): + def _get_rma_location_values(self, vals, code=False): """this method is intended to be used by 'create' method to create a new RMA location to be linked to a new warehouse. """ + company_id = vals.get( + "company_id", self.default_get(["company_id"])["company_id"] + ) + code = vals.get("code") or code or "" + code = code.replace(" ", "").upper() + view_location_id = vals.get("view_location_id") + view_location = ( + view_location_id + and self.view_location_id.browse(view_location_id) + or self.view_location_id + ) return { - "name": self.view_location_id.name, + "name": view_location.name, "active": True, "return_location": True, "usage": "internal", - "company_id": self.company_id.id, + "company_id": company_id, "location_id": self.env.ref("rma.stock_location_rma").id, + "barcode": self._valid_barcode(code + "-RMA", company_id), } + def _get_locations_values(self, vals, code=False): + res = super()._get_locations_values(vals, code) + res["rma_loc_id"] = self._get_rma_location_values(vals, code) + return res + def _get_sequence_values(self, name=False, code=False): values = super()._get_sequence_values(name=name, code=code) values.update( @@ -77,12 +82,14 @@ class StockWarehouse(models.Model): return values def _update_name_and_code(self, new_name=False, new_code=False): + res = super()._update_name_and_code(new_name, new_code) for warehouse in self: sequence_data = warehouse._get_sequence_values() warehouse.rma_in_type_id.sequence_id.write(sequence_data["rma_in_type_id"]) warehouse.rma_out_type_id.sequence_id.write( sequence_data["rma_out_type_id"] ) + return res def _get_picking_type_create_values(self, max_sequence): data, next_sequence = super()._get_picking_type_create_values(max_sequence) @@ -116,12 +123,13 @@ class StockWarehouse(models.Model): def _get_picking_type_update_values(self): data = super()._get_picking_type_update_values() - data.update( - { - "rma_in_type_id": {"default_location_dest_id": self.rma_loc_id.id}, - "rma_out_type_id": {"default_location_src_id": self.rma_loc_id.id}, - } - ) + picking_types = { + "rma_in_type_id": {"default_location_dest_id": self.rma_loc_id.id}, + "rma_out_type_id": {"default_location_src_id": self.rma_loc_id.id}, + } + if self.env.context.get("rma_post_init_hook"): + return picking_types + data.update(picking_types) return data def _create_or_update_sequences_and_picking_types(self): @@ -138,3 +146,70 @@ class StockWarehouse(models.Model): {"return_picking_type_id": data.get("rma_out_type_id", False)} ) return data + + def _get_routes_values(self): + res = super()._get_routes_values() + rma_routes = { + "rma_in_route_id": { + "routing_key": "rma_in", + "depends": ["active"], + "route_update_values": { + "name": self._format_routename("RMA In"), + "active": self.active, + }, + "route_create_values": { + "warehouse_selectable": True, + "company_id": self.company_id.id, + "sequence": 100, + }, + "rules_values": { + "active": True, + }, + }, + "rma_out_route_id": { + "routing_key": "rma_out", + "depends": ["active"], + "route_update_values": { + "name": self._format_routename("RMA Out"), + "active": self.active, + }, + "route_create_values": { + "warehouse_selectable": True, + "company_id": self.company_id.id, + "sequence": 110, + }, + "rules_values": { + "active": True, + }, + }, + } + if self.env.context.get("rma_post_init_hook"): + return rma_routes + res.update(rma_routes) + return res + + def get_rules_dict(self): + res = super().get_rules_dict() + customer_loc, supplier_loc = self._get_partner_locations() + for warehouse in self: + res[warehouse.id].update( + { + "rma_in": [ + self.Routing( + customer_loc, + warehouse.rma_loc_id, + warehouse.rma_in_type_id, + "pull", + ) + ], + "rma_out": [ + self.Routing( + warehouse.rma_loc_id, + customer_loc, + warehouse.rma_out_type_id, + "pull", + ) + ], + } + ) + return res diff --git a/rma/readme/CONTRIBUTORS.md b/rma/readme/CONTRIBUTORS.md index 36baf52b..801eb97b 100644 --- a/rma/readme/CONTRIBUTORS.md +++ b/rma/readme/CONTRIBUTORS.md @@ -7,3 +7,4 @@ - Giovanni Serra - Ooops \<\> - [APSL-Nagarro](https://www.apsl.tech): - Antoni Marroig \<\> +- Michael Tietz (MT Software) diff --git a/rma/readme/ROADMAP.md b/rma/readme/ROADMAP.md index 19864296..ee383231 100644 --- a/rma/readme/ROADMAP.md +++ b/rma/readme/ROADMAP.md @@ -1,3 +1,6 @@ - As soon as the picking is selected, the user should select the move, but perhaps stock.move \_rec_name could be improved to better show what the product of that move is. +- Add RMA reception and/or RMA delivery on several steps - 2 or 3 - like + normal receptions/deliveries. It should be a separate option inside the + warehouse definition. diff --git a/rma/static/description/index.html b/rma/static/description/index.html index e59c143b..b7e6b030 100644 --- a/rma/static/description/index.html +++ b/rma/static/description/index.html @@ -493,6 +493,9 @@ team will be the default one if no team is set.
  • As soon as the picking is selected, the user should select the move, but perhaps stock.move _rec_name could be improved to better show what the product of that move is.
  • +
  • Add RMA reception and/or RMA delivery on several steps - 2 or 3 - +like normal receptions/deliveries. It should be a separate option +inside the warehouse definition.
  • @@ -527,6 +530,7 @@ If you spotted it first, help us to smash it by providing a detailed and welcome
  • Antoni Marroig <amarroig@apsl.net>
  • +
  • Michael Tietz (MT Software) mtietz@mt-software.de
  • diff --git a/rma/tests/test_rma.py b/rma/tests/test_rma.py index 743226e8..6eb0987f 100644 --- a/rma/tests/test_rma.py +++ b/rma/tests/test_rma.py @@ -1,24 +1,20 @@ # Copyright 2020 Tecnativa - Ernesto Tejeda +# Copyright 2023 Michael Tietz (MT Software) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo.exceptions import UserError, ValidationError -from odoo.tests import Form, TransactionCase, new_test_user, users +from odoo.tests import Form, new_test_user, users +from odoo.tools import mute_logger + +from odoo.addons.base.tests.common import BaseCommon + +from .. import hooks -class TestRma(TransactionCase): +class TestRma(BaseCommon): @classmethod def setUpClass(cls): super().setUpClass() - cls.env = cls.env( - context=dict( - cls.env.context, - mail_create_nolog=True, - mail_create_nosubscribe=True, - mail_notrack=True, - no_reset_password=True, - tracking_disable=True, - ) - ) cls.user_rma = new_test_user( cls.env, login="user_rma", @@ -72,6 +68,7 @@ class TestRma(TransactionCase): {"name": "[Test] It's out of warranty. To be scrapped"} ) cls.env.ref("rma.group_rma_manual_finalization").users |= cls.env.user + cls.warehouse = cls.env.ref("stock.warehouse0") # Ensure grouping cls.env.company.rma_return_grouping = True @@ -95,7 +92,7 @@ class TestRma(TransactionCase): rma = self._create_rma(partner, product, qty, location) rma.action_confirm() rma.reception_move_id.quantity = rma.product_uom_qty - rma.reception_move_id.picking_id._action_done() + rma.reception_move_id.picking_id.button_validate() return rma def _create_delivery(self): @@ -132,6 +129,49 @@ class TestRma(TransactionCase): class TestRmaCase(TestRma): + def test_post_init_hook(self): + warehouse = self.env["stock.warehouse"].create( + { + "name": "Test warehouse", + "code": "code", + "company_id": self.env.company.id, + } + ) + hooks.post_init_hook(self.env) + self.assertTrue(warehouse.rma_in_type_id) + self.assertEqual( + warehouse.rma_in_type_id.default_location_dest_id, warehouse.rma_loc_id + ) + self.assertEqual( + warehouse.rma_out_type_id.default_location_src_id, warehouse.rma_loc_id + ) + self.assertTrue(warehouse.rma_loc_id) + self.assertTrue(warehouse.rma_in_route_id) + self.assertTrue(warehouse.rma_out_route_id) + + def test_rma_replace_pick_ship(self): + self.warehouse.write({"delivery_steps": "pick_ship"}) + rma = self._create_rma(self.partner, self.product, 1, self.rma_loc) + rma.action_confirm() + rma.reception_move_id.quantity = 1 + rma.reception_move_id.picking_id.button_validate() + self.assertEqual(rma.reception_move_id.picking_id.state, "done") + self.assertEqual(rma.state, "received") + res = rma.action_replace() + wizard_form = Form(self.env[res["res_model"]].with_context(**res["context"])) + wizard_form.product_id = self.product + wizard_form.product_uom_qty = rma.product_uom_qty + wizard = wizard_form.save() + wizard.action_deliver() + self.assertEqual(rma.delivery_picking_count, 2) + out_pickings = rma.mapped("delivery_move_ids.picking_id") + self.assertIn( + self.warehouse.pick_type_id, out_pickings.mapped("picking_type_id") + ) + self.assertIn( + self.warehouse.out_type_id, out_pickings.mapped("picking_type_id") + ) + def test_computed(self): # If partner changes, the invoice address is set rma = self.env["rma"].new() @@ -169,7 +209,7 @@ class TestRmaCase(TestRma): move.product_id = product_2 move.product_uom_qty = 15 picking = picking_form.save() - picking._action_done() + picking.button_validate() rma.picking_id = picking rma.move_id = picking.move_ids self.assertEqual(rma.product_id, product_2) @@ -207,13 +247,18 @@ class TestRmaCase(TestRma): self.assertEqual(rma.state, "confirmed") rma.reception_move_id.quantity = 9 with self.assertRaises(ValidationError): - rma.reception_move_id.picking_id._action_done() + res = rma.reception_move_id.picking_id.button_validate() + wizard = ( + self.env[res["res_model"]].with_context(**res["context"]).create({}) + ) + wizard.process() rma.reception_move_id.quantity = 10 - rma.reception_move_id.picking_id._action_done() + rma.reception_move_id.picking_id.button_validate() self.assertEqual(rma.reception_move_id.picking_id.state, "done") self.assertEqual(rma.reception_move_id.quantity, 10) self.assertEqual(rma.state, "received") + @mute_logger("odoo.models.unlink") def test_cancel(self): # cancel a draft RMA rma = self._create_rma(self.partner, self.product) @@ -333,7 +378,7 @@ class TestRmaCase(TestRma): # line of refund_1 self.assertEqual(len(refund_1.invoice_line_ids), 3) self.assertEqual( - refund_1.invoice_line_ids.mapped("rma_id"), + refund_1.invoice_line_ids.rma_id, (rma_1 | rma_2 | rma_3), ) self.assertEqual( @@ -524,6 +569,13 @@ class TestRmaCase(TestRma): all_rmas = rma_1 | rma_2 | rma_3 | rma_4 self.assertEqual(all_rmas.mapped("state"), ["received"] * 4) self.assertEqual(all_rmas.mapped("can_be_returned"), [True] * 4) + all_in_pickings = all_rmas.mapped("reception_move_id.picking_id") + self.assertEqual( + all_in_pickings.mapped("picking_type_id"), self.warehouse.rma_in_type_id + ) + self.assertEqual( + all_in_pickings.mapped("location_dest_id"), self.warehouse.rma_loc_id + ) # Mass return of those four RMAs delivery_wizard = ( self.env["rma.delivery.wizard"] @@ -534,6 +586,10 @@ class TestRmaCase(TestRma): # Two pickings were created pick_1 = (rma_1 | rma_2 | rma_3).mapped("delivery_move_ids.picking_id") pick_2 = rma_4.delivery_move_ids.picking_id + self.assertEqual(pick_1.picking_type_id, self.warehouse.rma_out_type_id) + self.assertEqual(pick_1.location_id, self.warehouse.rma_loc_id) + self.assertEqual(pick_2.picking_type_id, self.warehouse.rma_out_type_id) + self.assertEqual(pick_2.location_id, self.warehouse.rma_loc_id) self.assertEqual(len(pick_1), 1) self.assertEqual(len(pick_2), 1) self.assertNotEqual(pick_1, pick_2) @@ -549,7 +605,7 @@ class TestRmaCase(TestRma): # line of picking_1 self.assertEqual(len(pick_1.move_ids), 3) self.assertEqual( - pick_1.move_ids.mapped("rma_id"), + pick_1.move_ids.rma_id, (rma_1 | rma_2 | rma_3), ) self.assertEqual( @@ -620,14 +676,14 @@ class TestRmaCase(TestRma): origin_moves = origin_delivery.move_ids self.assertTrue(origin_moves[0].rma_ids) self.assertTrue(origin_moves[1].rma_ids) - rmas = origin_moves.mapped("rma_ids") + rmas = origin_moves.rma_ids self.assertEqual(rmas.mapped("state"), ["confirmed"] * 2) # Each reception move is linked one of the generated RMAs reception = self.env["stock.picking"].browse(picking_action["res_id"]) reception_moves = reception.move_ids self.assertTrue(reception_moves[0].rma_receiver_ids) self.assertTrue(reception_moves[1].rma_receiver_ids) - self.assertEqual(reception_moves.mapped("rma_receiver_ids"), rmas) + self.assertEqual(reception_moves.rma_receiver_ids, rmas) # Validate the reception picking to set rmas to 'received' state reception_moves[0].quantity = reception_moves[0].product_uom_qty reception_moves[1].quantity = reception_moves[1].product_uom_qty @@ -645,7 +701,7 @@ class TestRmaCase(TestRma): rma = rma_form.save() rma.action_confirm() rma.reception_move_id.quantity = 10 - rma.reception_move_id.picking_id._action_done() + rma.reception_move_id.picking_id.button_validate() # Return quantity 4 of the same product to the customer delivery_form = Form( self.env["rma.delivery.wizard"].with_context( @@ -686,6 +742,7 @@ class TestRmaCase(TestRma): self.assertEqual(new_rma.move_id.quantity, 10) self.assertEqual(new_rma.reception_move_id.quantity, 10) + @mute_logger("odoo.models.unlink") def test_rma_to_receive_on_delete_invoice(self): rma = self._create_confirm_receive(self.partner, self.product, 10, self.rma_loc) rma.action_refund() diff --git a/rma/wizard/stock_picking_return.py b/rma/wizard/stock_picking_return.py index 4599f1cf..7c7ca364 100644 --- a/rma/wizard/stock_picking_return.py +++ b/rma/wizard/stock_picking_return.py @@ -1,8 +1,25 @@ # Copyright 2020 Tecnativa - Ernesto Tejeda +# Copyright 2023 Michael Tietz (MT Software) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from copy import deepcopy from odoo import _, api, fields, models from odoo.exceptions import ValidationError +from odoo.tools import float_compare + + +class ReturnPickingLine(models.TransientModel): + _inherit = "stock.return.picking.line" + + def _prepare_rma_vals(self): + self.ensure_one() + return { + "move_id": self.move_id.id, + "product_id": self.move_id.product_id.id, + "product_uom_qty": self.quantity, + "product_uom": self.product_id.uom_id.id, + "location_id": self.wizard_id.location_id.id or self.move_id.location_id.id, + } class ReturnPicking(models.TransientModel): @@ -48,6 +65,52 @@ class ReturnPicking(models.TransientModel): location_id = return_picking_type.default_location_dest_id.id self.location_id = location_id + def _prepare_rma_partner_values(self): + self.ensure_one() + partner = self.picking_id.partner_id + partner_address = partner.address_get(["invoice", "delivery"]) + partner_invoice_id = partner_address.get("invoice", False) + partner_shipping_id = partner_address.get("delivery", False) + return ( + partner, + partner_invoice_id and partner.browse(partner_invoice_id) or partner, + partner_shipping_id and partner.browse(partner_shipping_id) or partner, + ) + + def _prepare_rma_vals(self): + partner, partner_invoice, partner_shipping = self._prepare_rma_partner_values() + origin = self.picking_id.name + vals = self.env["rma"]._prepare_procurement_group_vals() + vals["partner_id"] = partner_shipping.id + vals["name"] = origin + group = self.env["procurement.group"].create(vals) + return { + "user_id": self.env.user.id, + "partner_id": partner.id, + "partner_shipping_id": partner_shipping.id, + "partner_invoice_id": partner_invoice.id, + "origin": origin, + "picking_id": self.picking_id.id, + "company_id": self.company_id.id, + "procurement_group_id": group.id, + } + + def _prepare_rma_vals_list(self): + vals_list = [] + for return_picking in self: + global_vals = return_picking._prepare_rma_vals() + for line in return_picking.product_return_moves: + if ( + not line.move_id + or float_compare(line.quantity, 0, line.product_id.uom_id.rounding) + <= 0 + ): + continue + vals = deepcopy(global_vals) + vals.update(line._prepare_rma_vals()) + vals_list.append(vals) + return vals_list + def create_returns(self): """Override create_returns method for creating one or more 'confirmed' RMAs after return a delivery picking in case @@ -57,10 +120,6 @@ class ReturnPicking(models.TransientModel): as the 'Receipt'. """ if self.create_rma: - # set_rma_picking_type is to override the copy() method of stock - # picking and change the default picking type to rma picking type - self_with_context = self.with_context(set_rma_picking_type=True) - res = super(ReturnPicking, self_with_context).create_returns() if not self.picking_id.partner_id: raise ValidationError( _( @@ -68,12 +127,30 @@ class ReturnPicking(models.TransientModel): "'Stock Picking' from which RMAs will be created" ) ) - returned_picking = self.env["stock.picking"].browse(res["res_id"]) - vals_list = [ - move._prepare_return_rma_vals(self.picking_id) - for move in returned_picking.move_ids - ] - self.env["rma"].create(vals_list) - return res - else: - return super().create_returns() + vals_list = self._prepare_rma_vals_list() + rmas = self.env["rma"].create(vals_list) + rmas.action_confirm() + picking = rmas.reception_move_id.picking_id + picking = picking and picking[0] or picking + ctx = dict(self.env.context) + ctx.update( + { + "default_partner_id": picking.partner_id.id, + "search_default_picking_type_id": picking.picking_type_id.id, + "search_default_draft": False, + "search_default_assigned": False, + "search_default_confirmed": False, + "search_default_ready": False, + "search_default_planning_issues": False, + "search_default_available": False, + } + ) + return { + "name": _("Returned Picking"), + "view_mode": "form,tree,calendar", + "res_model": "stock.picking", + "res_id": picking.id, + "type": "ir.actions.act_window", + "context": ctx, + } + return super().create_returns()