-
-
Notifications
You must be signed in to change notification settings - Fork 233
[18.0][ADD] rma_sale_auto_detect #500
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 18.0
Are you sure you want to change the base?
Changes from all commits
98fab9e
0e1c0c0
23aba99
e407406
0cdcfb5
f2444a1
70e28c5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| from . import models |
| 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": [], | ||
| } |
| 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 |
| 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""" | ||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @sbejaoui Isn't using iterator better ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this a feature we want to keep in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good point, this can be in base module |
||
| 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, | ||
| ) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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