Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions rma_sale_auto_detect/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
====================
Rma Sale Auto Detect
====================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:e4afba9a3232f919bfd0c9cfe7e06a98cfd402846fbeb062ab39bec60c7f68dd
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frma-lightgray.png?logo=github
:target: https://github.com/OCA/rma/tree/18.0/rma_sale_auto_detect
:alt: OCA/rma
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/rma-18-0/rma-18-0-rma_sale_auto_detect
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/rma&target_branch=18.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

This addon automatically links RMA records to the most relevant sale
order lines based on delivered quantities and an eligibility period
defined on the RMA operation.

The module will:

- Search sale order lines delivered to the same partner
- Filter them by the operation's allowed return period
- Consume delivered quantities in chronological order
- Link the RMA to the corresponding stock move(s)
- Split the RMA if multiple deliveries or partial matches are needed
- Flag the RMA if no matching sale delivery was found

**Table of contents**

.. contents::
:local:

Use Cases / Context
===================

In many business flows, a returned product must be linked back to the
original sale in order to validate warranty conditions, refunds or
exchanges.

Manually searching the correct sale order for each RMA is error-prone
and time consuming, especially when:

- the customer has multiple past orders
- the product was delivered in several partial shipments
- the return period depends on the type of RMA operation (refund,
warranty, lifetime, etc.)

This module introduces an **automatic matching engine** that links RMA
records to the correct delivery moves of the original sale order, based
on delivered quantities and eligibility period.

It avoids manual reconciliation and provides a deterministic, auditable
match.

Usage
=====

1. Create an RMA in *Draft* state
2. Set the partner, product, quantity and operation
3. Click **"Link to sale order"**

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/rma/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/rma/issues/new?body=module:%20rma_sale_auto_detect%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* ACSONE SA/NV

Contributors
------------

- Souheil Bejaoui souheil.bejaoui@acsone.eu

Maintainers
-----------

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

This module is part of the `OCA/rma <https://github.com/OCA/rma/tree/18.0/rma_sale_auto_detect>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions rma_sale_auto_detect/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
15 changes: 15 additions & 0 deletions rma_sale_auto_detect/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright 2025 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

{
"name": "Rma Sale Auto Detect",
"summary": """Automatically link RMA products to related sales orders within an
eligibility period""",
"version": "18.0.1.0.0",
"license": "AGPL-3",
"author": "ACSONE SA/NV,Odoo Community Association (OCA)",
"website": "https://github.com/OCA/rma",
"depends": ["rma_sale"],
"data": ["views/rma.xml", "views/rma_operation.xml"],
"demo": [],
}
3 changes: 3 additions & 0 deletions rma_sale_auto_detect/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import rma_operation
from . import rma
from . import stock_move
232 changes: 232 additions & 0 deletions rma_sale_auto_detect/models/rma.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
# Copyright 2025 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from datetime import timedelta

from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools import float_compare


class Rma(models.Model):
_inherit = "rma"
has_sale_auto_detect_issue = fields.Boolean(readonly=True)
ignore_sale_auto_detect = fields.Boolean(readonly=True)
sale_auto_detect_note = fields.Text(readonly=True)
return_eligibility_days = fields.Integer(
string="Return eligibility duration (days)",
help=(
"Defines the time window in which sales can be linked "
"automatically to RMA lines. "
"Example: 30 days (change of mind), 730 days (warranty), "
"0 to disable, or a large number for lifetime warranty."
),
compute="_compute_return_eligibility_days",
store=True,
readonly=False,
)
return_eligibility_period_exceeded = fields.Boolean(
compute="_compute_return_eligibility_period_exceeded"
)

@api.depends("order_id.date_order", "return_eligibility_days", "state")
def _compute_return_eligibility_period_exceeded(self):
today = fields.Date.context_today(self)
for rec in self:
if rec.state != "draft" or not rec.order_id or not rec.order_id.date_order:
rec.return_eligibility_period_exceeded = False
continue
deadline = rec.order_id.date_order + timedelta(
days=rec.return_eligibility_days
)
rec.return_eligibility_period_exceeded = today > deadline.date()

@api.depends("operation_id")
def _compute_return_eligibility_days(self):
for rec in self:
if not rec.operation_id:
continue
rec.return_eligibility_days = rec.operation_id.return_eligibility_days

def action_link_rma_to_sale_line(self):
"""automatically link RMAs to the most relevant sale order lines"""
Comment on lines +51 to +52
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with how the code is written and organized. However I find it quite difficult to understand the logic when reading it.

I've seen the description in the README, but I don't know, shouldn't we add a bit more explanation here in the code? I'm open to discussion

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the eligibility period to the RMA simplified the code significantly, take another look and let me know what you think now

sol_model = self.env["sale.order.line"]
_filter_sol = self._filter_sale_lines_by_delivery_move
_sort_sol = self._sort_sale_lines_by_order_date
self.write(
{"has_sale_auto_detect_issue": False, "sale_auto_detect_note": False}
)
rma_to_link = self.filtered(
lambda r: not r.sale_line_id and not r.ignore_sale_auto_detect
)
for rma in rma_to_link:
if rma.order_id:
sale_lines = rma.order_id.order_line.filtered(
lambda line, r=rma: line.product_id == r.product_id
)
else:
sale_lines = sol_model.search(
rma._get_eligible_sale_lines_domain(),
)

sale_lines = _filter_sol(sale_lines)
sale_lines = _sort_sol(sale_lines)
rma._link_rma_to_sale_line(sale_lines)
# Mark remaining unmatched RMAs
not_linked_rmas = rma_to_link.filtered(lambda r: not r.move_id)
not_linked_rmas.has_sale_auto_detect_issue = True
not_linked_rmas.sale_auto_detect_note = _(
"No delivery move found or insufficient delivered quantity."
)
return True

def _get_eligible_sale_lines_domain(self):
self.ensure_one()
return_eligibility_days = self.return_eligibility_days or 0
oldest_date = fields.Date.to_date(fields.Date.today()) - timedelta(
days=return_eligibility_days
)
return [
("order_id.partner_id", "child_of", self.partner_id.id),
("state", "in", ["sale", "done"]),
("order_id.date_order", ">=", oldest_date),
("product_id", "=", self.product_id.id),
]

@api.model
def _filter_sale_lines_by_delivery_move(self, sale_lines):
"""return only sale order lines whose delivered moves still have returnable
quantity"""
precision = self.env["decimal.precision"].precision_get(
"Product Unit of Measure"
)

return sale_lines.filtered(
lambda sol: any(
m.state == "done"
and float_compare(
m.rma_returnable_uom_qty, 0, precision_digits=precision
)
> 0
for m in sol.move_ids
)
)

@api.model
def _sort_sale_lines_by_order_date(self, sale_lines):
return sale_lines.sorted(lambda sol: (sol.order_id.date_order, sol.id))

def _link_rma_to_sale_line(self, sale_lines):
"""match between rmas and sale lines"""
if not sale_lines:
return False
sale_line_delivered_qty = self._get_sale_line_returnable_qty(sale_lines)
rmas = self.sorted("date")
sale_lines = sale_lines.sorted(lambda sol: (sol.order_id.date_order, sol.id))

rma_index = 0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sbejaoui Isn't using iterator better ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, we’re doing an asymmetric loop over two sorted lists (sometimes advancing sales, sometimes RMAs, sometimes both), so using indices is easier to read.

Switching to iterators wouldn’t really simplify the code, and there’s no performance gain either, since we’d still need to keep track of the current rma and sale_line, initialize the iterators, and handle exhaustion explicitly.

sale_index = 0

while rma_index < len(rmas) and sale_index < len(sale_lines):
rma = rmas[rma_index]
sale_line = sale_lines[sale_index]
remaining_qty = sale_line_delivered_qty.get(sale_line.id, 0.0)

if remaining_qty <= 0:
sale_index += 1
continue

rma_qty = rma.product_uom_qty

if rma_qty == remaining_qty:
# perfect match
rma._link_rma_to_delivery_move(sale_line)
sale_line_delivered_qty[sale_line.id] = 0.0
rma_index += 1
sale_index += 1
elif rma_qty > remaining_qty:
# rma needs more than available on this sale line
# we copy RMA for the matched qty
matched_rma = rma.copy({"product_uom_qty": remaining_qty})
# reduce qty on original RMA
rma.product_uom_qty = rma_qty - remaining_qty
# link the matched copy to the sale line
matched_rma._link_rma_to_delivery_move(sale_line)
sale_line_delivered_qty[sale_line.id] = 0.0
sale_index += 1
else:
# rma quantity smaller than available delivered qty
rma._link_rma_to_delivery_move(sale_line, qty_limit=rma_qty)
sale_line_delivered_qty[sale_line.id] = remaining_qty - rma_qty
rma_index += 1

@api.model
def _get_sale_line_returnable_qty(self, sale_lines):
"""return a dict mapping sale_line.id -> delivered quantity"""
return {
line.id: sum(line.move_ids.mapped("rma_returnable_uom_qty"))
if line.move_ids
else 0.0
for line in sale_lines
}

def _link_rma_to_delivery_move(self, sale_line, qty_limit=None):
"""assign stock moves from a sale line to an rma
qty_limit can be used to cap the total assigned quantity
"""
self.ensure_one()
delivery_moves = sale_line.move_ids.filtered(
lambda m: m.state == "done"
).sorted(lambda m: (m.date, m.id))
if not delivery_moves:
return

total_assigned = 0.0
for i, move in enumerate(delivery_moves):
move_qty = move.rma_returnable_uom_qty
if qty_limit and total_assigned + move_qty > qty_limit:
move_qty = qty_limit - total_assigned
total_assigned += move_qty
if not move_qty or qty_limit and total_assigned > qty_limit:
break
values = {
"move_id": move.id,
"picking_id": move.picking_id.id,
"product_uom_qty": move_qty,
"order_id": sale_line.order_id.id,
}
if i == 0:
self.write(values)
else:
# duplicate for each additional move
self.copy(values)

def action_draft(self):
res = super().action_draft()
self.filtered(lambda r: r.state == "draft").write(
{"has_sale_auto_detect_issue": False, "sale_auto_detect_note": False}
)
return res

def action_confirm(self):
precision = self.env["decimal.precision"].precision_get(
"Product Unit of Measure"
)

for rec in self:
if rec.move_id and (
float_compare(
rec.move_id.rma_returnable_uom_qty,
0,
precision_digits=precision,
)
< 0
):
raise ValidationError(
_(
"The quantity to return exceeds the remaining returnable "
"quantity for this delivery."
)
)

return super().action_confirm()
Comment on lines +211 to +232
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a feature we want to keep in rma_sale_auto_detect? Shouldn't it be interesting in a more generic addon?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, this can be in base module

19 changes: 19 additions & 0 deletions rma_sale_auto_detect/models/rma_operation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright 2025 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import fields, models


class RmaOperation(models.Model):
_inherit = "rma.operation"

return_eligibility_days = fields.Integer(
string="Return eligibility duration (days)",
help=(
"Defines the time window in which sales can be linked "
"automatically to RMA lines. "
"Example: 30 days (change of mind), 730 days (warranty), "
"0 to disable, or a large number for lifetime warranty."
),
default=30,
)
Loading