[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
This commit is contained in:
Michael Tietz
2024-04-16 00:08:46 +02:00
committed by Víctor Martínez
parent ada6cab4dc
commit 08a492ca76
19 changed files with 608 additions and 375 deletions

View File

@@ -134,6 +134,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
===========
@@ -165,6 +168,7 @@ Contributors
* Chafique Delli <chafique.delli@akretion.com>
* Giovanni Serra - Ooops <giovanni@ooops404.com>
* Michael Tietz (MT Software) <mtietz@mt-software.de>
Maintainers
~~~~~~~~~~~

View File

@@ -5,7 +5,7 @@
{
"name": "Return Merchandise Authorization Management",
"summary": "Return Merchandise Authorization (RMA)",
"version": "16.0.1.3.0",
"version": "16.0.1.4.0",
"development_status": "Production/Stable",
"category": "RMA",
"website": "https://github.com/OCA/rma",

View File

@@ -1,4 +1,5 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import SUPERUSER_ID, api
@@ -24,7 +25,9 @@ def post_init_hook(cr, registry):
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)
@@ -61,11 +64,19 @@ def post_init_hook(cr, registry):
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()

View File

@@ -0,0 +1,14 @@
# 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:
route_vals = wh._create_or_update_route()
wh.write(route_vals)

View File

@@ -1,12 +1,13 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# Copyright 2023 Tecnativa - Pedro M. Baeza
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# 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
@@ -317,18 +318,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",
@@ -670,21 +661,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()
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
@@ -723,11 +778,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)
@@ -746,11 +798,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(
@@ -766,11 +815,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)
@@ -782,11 +828,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)
@@ -794,7 +837,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):
@@ -819,25 +862,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()
@@ -853,23 +899,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):
@@ -985,81 +1015,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 = "{} ({})".format(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_view(
"mail.message_origin_link",
values={"self": picking, "origin": self},
subtype_id=self.env.ref("mail.mt_note").id,
)
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()
@@ -1133,44 +1088,100 @@ 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: <a href="#" data-oe-model="stock.picking" '
'data-oe-id="%(id)d">%(name)s</a> 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: <a href="#" data-oe-model="stock.picking" '
'data-oe-id="%(id)d">%(name)s</a> 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_view(
@@ -1180,42 +1191,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
@@ -1239,6 +1261,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 _(
@@ -1251,74 +1279,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):

View File

@@ -1,4 +1,5 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
@@ -22,7 +23,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",
@@ -32,8 +33,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"}
@@ -103,38 +104,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

View File

@@ -30,8 +30,8 @@ class StockPicking(models.Model):
def action_view_rma(self):
self.ensure_one()
action = self.sudo().env.ref("rma.rma_action").read()[0]
rma = self.move_ids.mapped("rma_ids")
action = self.env["ir.actions.act_window"]._for_xml_id("rma.rma_action")
rma = self.move_lines.mapped("rma_ids")
if len(rma) == 1:
action.update(
res_id=rma.id,

View File

@@ -1,7 +1,8 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# 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)
@@ -138,3 +145,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

View File

@@ -7,3 +7,4 @@
* Chafique Delli <chafique.delli@akretion.com>
* Giovanni Serra - Ooops <giovanni@ooops404.com>
* Michael Tietz (MT Software) <mtietz@mt-software.de>

View File

@@ -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.

View File

@@ -488,6 +488,9 @@ will be the default one if no team is set.</li>
<li>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.</li>
<li>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.</li>
</ul>
</div>
<div class="section" id="bug-tracker">
@@ -518,6 +521,7 @@ If you spotted it first, help us to smash it by providing a detailed and welcome
</li>
<li>Chafique Delli &lt;<a class="reference external" href="mailto:chafique.delli&#64;akretion.com">chafique.delli&#64;akretion.com</a>&gt;</li>
<li>Giovanni Serra - Ooops &lt;<a class="reference external" href="mailto:giovanni&#64;ooops404.com">giovanni&#64;ooops404.com</a>&gt;</li>
<li>Michael Tietz (MT Software) &lt;<a class="reference external" href="mailto:mtietz&#64;mt-software.de">mtietz&#64;mt-software.de</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">

View File

@@ -1,9 +1,12 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# 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 .. import hooks
class TestRma(TransactionCase):
@classmethod
@@ -72,6 +75,8 @@ 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.env.ref("stock.group_stock_multi_locations").users |= cls.env.user
cls.warehouse = cls.env.ref("stock.warehouse0")
# Ensure grouping
cls.env.company.rma_return_grouping = True
@@ -130,6 +135,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.cr, self.registry)
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_done = 1
rma.reception_move_id.picking_id._action_done()
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()
@@ -331,7 +379,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(
@@ -538,6 +586,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"]
@@ -548,6 +603,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)
@@ -563,7 +622,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(
@@ -634,14 +693,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_done = reception_moves[0].product_uom_qty
reception_moves[1].quantity_done = reception_moves[1].product_uom_qty

View File

@@ -1,8 +1,25 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# 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()

View File

@@ -4,4 +4,3 @@ from . import res_company
from . import res_config_settings
from . import rma
from . import sale
from . import stock_move

View File

@@ -163,3 +163,15 @@ class Rma(models.Model):
):
vals["sale_line_ids"] = [(4, line.id)]
return vals
def _prepare_procurement_group_vals(self):
vals = super()._prepare_procurement_group_vals()
if not self.env.context.get("ignore_rma_sale_order") and self.order_id:
vals["sale_id"] = self.order_id.id
return vals
def _prepare_delivery_procurements(self, scheduled_date=None, qty=None, uom=None):
self = self.with_context(ignore_rma_sale_order=True)
return super()._prepare_delivery_procurements(
scheduled_date=scheduled_date, qty=qty, uom=uom
)

View File

@@ -1,4 +1,5 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
@@ -118,10 +119,23 @@ class SaleOrderLine(models.Model):
self.ensure_one()
# Method helper to filter chained moves
def destination_moves(_move):
return _move.mapped("move_dest_ids").filtered(
def _get_chained_moves(_moves, done_moves=None):
moves = _moves.browse()
done_moves = done_moves or _moves.browse()
for move in _moves:
if move.location_dest_id.usage == "customer":
moves |= move.returned_move_ids
else:
moves |= move.move_dest_ids
done_moves |= _moves
moves = moves.filtered(
lambda r: r.state in ["partially_available", "assigned", "done"]
)
if not moves:
return moves
moves -= done_moves
moves |= _get_chained_moves(moves, done_moves)
return moves
product = self.product_id
if self.product_id.type not in ["product", "consu"]:
@@ -134,21 +148,13 @@ class SaleOrderLine(models.Model):
# to return. When a product is re-delivered it should be
# allowed to open an RMA again on it.
qty = move.product_uom_qty
qty_returned = 0
move_dest = destination_moves(move)
# With the return of the return of the return we could have an
# infinite loop, so we should avoid it dropping already explored
# move_dest_ids
visited_moves = move + move_dest
while move_dest:
qty_returned -= sum(move_dest.mapped("product_uom_qty"))
move_dest = destination_moves(move_dest) - visited_moves
if move_dest:
visited_moves += move_dest
qty += sum(move_dest.mapped("product_uom_qty"))
move_dest = destination_moves(move_dest) - visited_moves
for _move in _get_chained_moves(move):
factor = 1
if _move.location_dest_id.usage != "customer":
factor = -1
qty += factor * _move.product_uom_qty
# If by chance we get a negative qty we should ignore it
qty = max(0, sum((qty, qty_returned)))
qty = max(0, qty)
data.append(
{
"product": move.product_id,

View File

@@ -1,13 +0,0 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import models
class StockMove(models.Model):
_inherit = "stock.move"
def _prepare_return_rma_vals(self, original_picking):
res = super()._prepare_return_rma_vals(original_picking)
res.update(order_id=original_picking.sale_id.id)
return res

View File

@@ -1,3 +1,4 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import sale_order_rma_wizard
from . import stock_picking_return

View File

@@ -0,0 +1,29 @@
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import models
class ReturnPicking(models.TransientModel):
_inherit = "stock.return.picking"
def _prepare_rma_partner_values(self):
sale_order = self.picking_id.sale_id
if not sale_order:
return super()._prepare_rma_partner_values()
return (
sale_order.partner_id,
sale_order.partner_invoice_id,
sale_order.partner_shipping_id,
)
def _prepare_rma_values(self):
vals = super()._prepare_rma_values()
sale_order = self.picking_id.sale_id
if sale_order:
vals.update(
{
"order_id": sale_order.id,
}
)
return vals