diff --git a/rma_sale_auto_detect/README.rst b/rma_sale_auto_detect/README.rst new file mode 100644 index 000000000..6985cdb22 --- /dev/null +++ b/rma_sale_auto_detect/README.rst @@ -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 `_. +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 `_. + +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 `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/rma_sale_auto_detect/__init__.py b/rma_sale_auto_detect/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/rma_sale_auto_detect/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/rma_sale_auto_detect/__manifest__.py b/rma_sale_auto_detect/__manifest__.py new file mode 100644 index 000000000..061150cd5 --- /dev/null +++ b/rma_sale_auto_detect/__manifest__.py @@ -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": [], +} diff --git a/rma_sale_auto_detect/models/__init__.py b/rma_sale_auto_detect/models/__init__.py new file mode 100644 index 000000000..20d517717 --- /dev/null +++ b/rma_sale_auto_detect/models/__init__.py @@ -0,0 +1,3 @@ +from . import rma_operation +from . import rma +from . import stock_move diff --git a/rma_sale_auto_detect/models/rma.py b/rma_sale_auto_detect/models/rma.py new file mode 100644 index 000000000..ad43c7d3f --- /dev/null +++ b/rma_sale_auto_detect/models/rma.py @@ -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 + 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() diff --git a/rma_sale_auto_detect/models/rma_operation.py b/rma_sale_auto_detect/models/rma_operation.py new file mode 100644 index 000000000..f43c60748 --- /dev/null +++ b/rma_sale_auto_detect/models/rma_operation.py @@ -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, + ) diff --git a/rma_sale_auto_detect/models/stock_move.py b/rma_sale_auto_detect/models/stock_move.py new file mode 100644 index 000000000..9c4dcd667 --- /dev/null +++ b/rma_sale_auto_detect/models/stock_move.py @@ -0,0 +1,29 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class StockMove(models.Model): + _inherit = "stock.move" + + rma_returnable_uom_qty = fields.Float( + compute="_compute_rma_returnable_uom_qty", + string="RMA Returnable Quantity", + digits="Product Unit of Measure", + help="Quantity of this delivery move still eligible to be returned through RMA", + ) + + @api.depends("rma_ids", "product_uom_qty", "picking_type_id.code") + def _compute_rma_returnable_uom_qty(self): + for move in self: + if move.picking_type_id.code != "outgoing": + move.rma_returnable_uom_qty = 0 + continue + + already_in_rma = sum( + rma.product_uom._compute_quantity(rma.product_uom_qty, move.product_uom) + for rma in move.rma_ids + if rma.state != "cancelled" + ) + move.rma_returnable_uom_qty = move.product_uom_qty - already_in_rma diff --git a/rma_sale_auto_detect/pyproject.toml b/rma_sale_auto_detect/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/rma_sale_auto_detect/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/rma_sale_auto_detect/readme/CONTEXT.md b/rma_sale_auto_detect/readme/CONTEXT.md new file mode 100644 index 000000000..df28d8088 --- /dev/null +++ b/rma_sale_auto_detect/readme/CONTEXT.md @@ -0,0 +1,15 @@ +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. \ No newline at end of file diff --git a/rma_sale_auto_detect/readme/CONTRIBUTORS.md b/rma_sale_auto_detect/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..dbdd727b4 --- /dev/null +++ b/rma_sale_auto_detect/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Souheil Bejaoui diff --git a/rma_sale_auto_detect/readme/DESCRIPTION.md b/rma_sale_auto_detect/readme/DESCRIPTION.md new file mode 100644 index 000000000..6c426522d --- /dev/null +++ b/rma_sale_auto_detect/readme/DESCRIPTION.md @@ -0,0 +1,12 @@ +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 diff --git a/rma_sale_auto_detect/readme/USAGE.md b/rma_sale_auto_detect/readme/USAGE.md new file mode 100644 index 000000000..a5652aea3 --- /dev/null +++ b/rma_sale_auto_detect/readme/USAGE.md @@ -0,0 +1,3 @@ +1. Create an RMA in *Draft* state +2. Set the partner, product, quantity and operation +3. Click **"Link to sale order"** diff --git a/rma_sale_auto_detect/static/description/icon.png b/rma_sale_auto_detect/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/rma_sale_auto_detect/static/description/icon.png differ diff --git a/rma_sale_auto_detect/static/description/index.html b/rma_sale_auto_detect/static/description/index.html new file mode 100644 index 000000000..847d43c4d --- /dev/null +++ b/rma_sale_auto_detect/static/description/index.html @@ -0,0 +1,463 @@ + + + + + +Rma Sale Auto Detect + + + +
+

Rma Sale Auto Detect

+ + +

Beta License: AGPL-3 OCA/rma Translate me on Weblate Try me on Runboat

+

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

+ +
+

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. +
  3. Set the partner, product, quantity and operation
  4. +
  5. Click “Link to sale order”
  6. +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub 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.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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 project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/rma_sale_auto_detect/tests/__init__.py b/rma_sale_auto_detect/tests/__init__.py new file mode 100644 index 000000000..583d587ea --- /dev/null +++ b/rma_sale_auto_detect/tests/__init__.py @@ -0,0 +1 @@ +from . import test_rma_sale_auto_detect diff --git a/rma_sale_auto_detect/tests/common.py b/rma_sale_auto_detect/tests/common.py new file mode 100644 index 000000000..28312a092 --- /dev/null +++ b/rma_sale_auto_detect/tests/common.py @@ -0,0 +1,65 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import Command +from odoo.fields import Date +from odoo.tests.common import TransactionCase + + +class TestRmaSaleAutoDetectBase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Customer"}) + cls.partner2 = cls.env["res.partner"].create({"name": "Customer 2"}) + cls.product = cls.env["product.product"].create( + {"name": "Test Product", "type": "consu", "is_storable": True} + ) + cls.product2 = cls.env["product.product"].create( + {"name": "Test Product 2", "type": "consu", "is_storable": True} + ) + cls.operation = cls.env["rma.operation"].create( + {"name": "Warranty Return", "return_eligibility_days": 30} + ) + cls.loc_stock = cls.env.ref("stock.stock_location_stock") + cls.env["stock.quant"]._update_available_quantity( + cls.product, cls.loc_stock, 100 + ) + cls.env["stock.quant"]._update_available_quantity( + cls.product2, cls.loc_stock, 100 + ) + + def _create_and_confirm_sale_order(self, partner, products, timedelta_days): + sale_order = self.env["sale.order"].create( + { + "partner_id": partner.id, + "order_line": [ + Command.create({"product_id": product.id, "product_uom_qty": qty}) + for product, qty in products + ], + } + ) + sale_order.action_confirm() + sale_order.date_order = Date.today() - timedelta(days=timedelta_days) + return sale_order + + def _process_picking(self, picking, product, qty): + move = picking.move_ids.filtered( + lambda m: m.product_id == product and m.state == "assigned" + ) + move.quantity = qty + move.picked = True + move._action_done() + self.assertEqual(move.state, "done") + + def _create_rma(self, partner, product, qty, operation): + return self.env["rma"].create( + { + "partner_id": partner.id, + "product_id": product.id, + "operation_id": operation.id, + "product_uom_qty": qty, + } + ) diff --git a/rma_sale_auto_detect/tests/test_rma_sale_auto_detect.py b/rma_sale_auto_detect/tests/test_rma_sale_auto_detect.py new file mode 100644 index 000000000..4dec02203 --- /dev/null +++ b/rma_sale_auto_detect/tests/test_rma_sale_auto_detect.py @@ -0,0 +1,294 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.exceptions import ValidationError + +from .common import TestRmaSaleAutoDetectBase + + +class TestRmaSaleAutoDetect(TestRmaSaleAutoDetectBase): + def test_0(self): + """sale order older than the operation return eligibility period should not be + linked automatically + if a sale order is suggested by the user the link is created even if the + eligibility period is not respected""" + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 5)], 60 + ) + self._process_picking(sale_order.picking_ids, self.product, 5) + rma = self._create_rma(self.partner, self.product, 5, self.operation) + rma.action_link_rma_to_sale_line() + self.assertFalse(rma.move_id) + self.assertTrue(rma.has_sale_auto_detect_issue) + self.assertEqual( + rma.sale_auto_detect_note, + "No delivery move found or insufficient delivered quantity.", + ) + rma.order_id = sale_order + rma.action_link_rma_to_sale_line() + self.assertTrue(rma.move_id) + self.assertFalse(rma.has_sale_auto_detect_issue) + self.assertFalse(rma.sale_auto_detect_note) + + def test_1(self): + """exact match between rma and sale line delivered qty with one delivery""" + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 5)], 30 + ) + self._process_picking(sale_order.picking_ids, self.product, 5) + rma = self._create_rma(self.partner, self.product, 5, self.operation) + rma.action_link_rma_to_sale_line() + self.assertEqual(rma.move_id, sale_order.order_line.move_ids) + self.assertEqual(rma.sale_line_id, sale_order.order_line) + self.assertEqual(rma.order_id, sale_order) + + def test_2(self): + """exact match between rma and sale line delivered qty with multiple + deliveries""" + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 5)], 30 + ) + self._process_picking(sale_order.picking_ids, self.product, 3) + self._process_picking(sale_order.picking_ids, self.product, 2) + self.assertEqual(len(sale_order.order_line.move_ids), 2) + rma = self._create_rma(self.partner, self.product, 5, self.operation) + rma.action_link_rma_to_sale_line() + self.assertTrue(rma.move_id) + self.assertEqual(rma.sale_line_id, sale_order.order_line) + self.assertEqual(rma.order_id, sale_order) + self.assertEqual(len(sale_order.rma_ids), 2) + self.assertEqual(sum(sale_order.rma_ids.mapped("product_uom_qty")), 5) + + def test_3(self): + """rma qty greater than sale line delivered qty with one delivery""" + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 5)], 30 + ) + self._process_picking(sale_order.picking_ids, self.product, 5) + rma = self._create_rma(self.partner, self.product, 10, self.operation) + rma.action_link_rma_to_sale_line() + self.assertFalse(rma.move_id) + self.assertEqual(rma.product_uom_qty, 5) + new_rma = sale_order.rma_ids # a new rma was created with the matched qty + self.assertEqual(new_rma.sale_line_id, sale_order.order_line) + self.assertEqual(new_rma.order_id, sale_order) + self.assertEqual(new_rma.product_uom_qty, 5) + self.assertTrue(rma.has_sale_auto_detect_issue) + self.assertEqual( + rma.sale_auto_detect_note, + "No delivery move found or insufficient delivered quantity.", + ) + + def test_4(self): + """rma qty greater than sale line delivered qty with multiple deliveries""" + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 5)], 30 + ) + self._process_picking(sale_order.picking_ids, self.product, 5) + rma = self._create_rma(self.partner, self.product, 10, self.operation) + rma.action_link_rma_to_sale_line() + self.assertFalse(rma.move_id) + self.assertEqual(rma.product_uom_qty, 5) + new_rma = sale_order.rma_ids # a new rma was created with the matched qty + self.assertEqual(new_rma.sale_line_id, sale_order.order_line) + self.assertEqual(new_rma.order_id, sale_order) + self.assertEqual(new_rma.product_uom_qty, 5) + + def test_5(self): + """rma qty smaller than sale line delivered qty with one delivery""" + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 10)], 30 + ) + self._process_picking(sale_order.picking_ids, self.product, 10) + rma = self._create_rma(self.partner, self.product, 5, self.operation) + rma.action_link_rma_to_sale_line() + self.assertEqual(rma.product_uom_qty, 5) + self.assertEqual(rma.sale_line_id, sale_order.order_line) + self.assertEqual(rma.order_id, sale_order) + self.assertEqual(rma.product_uom_qty, 5) + + def test_6(self): + """rma qty smaller than sale line delivered qty with multiple deliveries""" + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 10)], 30 + ) + self._process_picking(sale_order.picking_ids, self.product, 3) + self._process_picking(sale_order.picking_ids, self.product, 2) + self._process_picking(sale_order.picking_ids, self.product, 5) + rma = self._create_rma(self.partner, self.product, 5, self.operation) + rma.action_link_rma_to_sale_line() + matched_rmas = sale_order.rma_ids + self.assertEqual(len(matched_rmas), 2) + self.assertEqual(sum(matched_rmas.mapped("product_uom_qty")), 5) + self.assertEqual(matched_rmas.sale_line_id, sale_order.order_line) + self.assertEqual(matched_rmas.order_id, sale_order) + + def test_7(self): + """rma linked to sale orders with different partners""" + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 5)], 30 + ) + sale_order2 = self._create_and_confirm_sale_order( + self.partner2, [(self.product, 5)], 30 + ) + self._process_picking(sale_order.picking_ids, self.product, 5) + self._process_picking(sale_order2.picking_ids, self.product, 5) + rma = self._create_rma(self.partner, self.product, 5, self.operation) + rma2 = self._create_rma(self.partner2, self.product, 5, self.operation) + (rma + rma2).action_link_rma_to_sale_line() + self.assertEqual(rma.sale_line_id, sale_order.order_line) + self.assertEqual(rma.order_id, sale_order) + self.assertEqual(rma2.sale_line_id, sale_order2.order_line) + self.assertEqual(rma2.order_id, sale_order2) + + def test_8(self): + """rma linked to sale orders with different partners and products""" + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 5), (self.product2, 6)], 30 + ) + sale_order2 = self._create_and_confirm_sale_order( + self.partner2, [(self.product, 5), (self.product2, 6)], 30 + ) + self._process_picking(sale_order.picking_ids, self.product, 5) + self._process_picking(sale_order.picking_ids, self.product2, 5) + self._process_picking(sale_order2.picking_ids, self.product, 5) + self._process_picking(sale_order2.picking_ids, self.product2, 6) + rma = self._create_rma(self.partner, self.product, 5, self.operation) + rma2 = self._create_rma(self.partner2, self.product, 5, self.operation) + rma3 = self._create_rma(self.partner2, self.product2, 5, self.operation) + (rma + rma2 + rma3).action_link_rma_to_sale_line() + + self.assertEqual( + rma.sale_line_id, + sale_order.order_line.filtered(lambda sol: sol.product_id == self.product), + ) + self.assertEqual(rma.order_id, sale_order) + self.assertEqual( + rma2.sale_line_id, + sale_order2.order_line.filtered(lambda sol: sol.product_id == self.product), + ) + self.assertEqual(rma2.order_id, sale_order2) + self.assertEqual( + rma3.sale_line_id, + sale_order2.order_line.filtered( + lambda sol: sol.product_id == self.product2 + ), + ) + self.assertEqual(rma2.order_id, sale_order2) + + def test_9(self): + """multiple rmas linked to the same sale line""" + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 10)], 30 + ) + self._process_picking(sale_order.picking_ids, self.product, 10) + rma = self._create_rma(self.partner, self.product, 5, self.operation) + rma2 = self._create_rma(self.partner, self.product, 3, self.operation) + rma3 = self._create_rma(self.partner, self.product, 2, self.operation) + (rma + rma2 + rma3).action_link_rma_to_sale_line() + self.assertEqual(rma.sale_line_id, sale_order.order_line) + self.assertEqual(rma2.sale_line_id, sale_order.order_line) + self.assertEqual(rma3.sale_line_id, sale_order.order_line) + rma4 = self._create_rma(self.partner, self.product, 2, self.operation) + rma4.action_link_rma_to_sale_line() + # all delivered qty already linked, new rma should not be linked + self.assertFalse(rma4.sale_line_id) + + def test_10(self): + """When user suggest the sale order and there is multiple delivery moves + the rma is split""" + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 5)], 60 + ) + self._process_picking(sale_order.picking_ids, self.product, 3) + self._process_picking(sale_order.picking_ids, self.product, 2) + rma = self._create_rma(self.partner, self.product, 5, self.operation) + self.assertFalse(rma.return_eligibility_period_exceeded) + rma.order_id = sale_order + self.assertTrue(rma.return_eligibility_period_exceeded) + rma.action_link_rma_to_sale_line() + self.assertTrue(rma.move_id) + self.assertFalse(rma.has_sale_auto_detect_issue) + self.assertFalse(rma.sale_auto_detect_note) + rmas = sale_order.order_line.move_ids.rma_ids + self.assertEqual(len(rmas), 2) + self.assertEqual(sum(rmas.mapped("product_uom_qty")), 5) + + def test_11(self): + """sale order older than the operation return eligibility period should not be + linked automatically, the ignore_sale_auto_detect should ignore the linking + issue""" + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 5)], 60 + ) + self._process_picking(sale_order.picking_ids, self.product, 5) + rma = self._create_rma(self.partner, self.product, 5, self.operation) + rma.action_link_rma_to_sale_line() + self.assertFalse(rma.move_id) + self.assertTrue(rma.has_sale_auto_detect_issue) + self.assertEqual( + rma.sale_auto_detect_note, + "No delivery move found or insufficient delivered quantity.", + ) + rma.ignore_sale_auto_detect = True + rma.action_link_rma_to_sale_line() + self.assertFalse(rma.move_id) + self.assertFalse(rma.has_sale_auto_detect_issue) + self.assertFalse(rma.sale_auto_detect_note) + + def test_12(self): + """ + partial return: + test that RMAs are linked to the sale line until the delivered quantity is + fully consumed, and that any additional RMA is left unlinked + """ + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 10)], 30 + ) + self._process_picking(sale_order.picking_ids, self.product, 10) + rma = self._create_rma(self.partner, self.product, 5, self.operation) + rma2 = self._create_rma(self.partner, self.product, 3, self.operation) + (rma + rma2).action_link_rma_to_sale_line() + self.assertEqual(rma.sale_line_id, sale_order.order_line) + self.assertEqual(rma2.sale_line_id, sale_order.order_line) + rma3 = self._create_rma(self.partner, self.product, 2, self.operation) + rma3.action_link_rma_to_sale_line() + self.assertEqual(rma3.sale_line_id, sale_order.order_line) + rma4 = self._create_rma(self.partner, self.product, 2, self.operation) + rma4.action_link_rma_to_sale_line() + # all delivered qty already linked, new rma should not be linked + self.assertFalse(rma4.sale_line_id) + (rma + rma2 + rma3).action_confirm() + self.assertEqual(rma.state, "confirmed") + # force th move + rma4.write({"move_id": sale_order.order_line.move_ids.id}) + with self.assertRaisesRegex( + ValidationError, + "The quantity to return exceeds the remaining returnable " + "quantity for this delivery", + ): + rma4.action_confirm() + + def test_13(self): + """has_sale_auto_detect_issue is reset after rma set to draft""" + sale_order = self._create_and_confirm_sale_order( + self.partner, [(self.product, 5)], 60 + ) + self._process_picking(sale_order.picking_ids, self.product, 5) + rma = self._create_rma(self.partner, self.product, 5, self.operation) + rma.action_link_rma_to_sale_line() + self.assertFalse(rma.move_id) + self.assertTrue(rma.has_sale_auto_detect_issue) + self.assertEqual( + rma.sale_auto_detect_note, + "No delivery move found or insufficient delivered quantity.", + ) + rma.action_cancel() + self.assertEqual(rma.state, "cancelled") + self.assertTrue(rma.has_sale_auto_detect_issue) + self.assertTrue(rma.sale_auto_detect_note) + rma.action_draft() + self.assertEqual(rma.state, "draft") + self.assertFalse(rma.has_sale_auto_detect_issue) + self.assertFalse(rma.sale_auto_detect_note) diff --git a/rma_sale_auto_detect/views/rma.xml b/rma_sale_auto_detect/views/rma.xml new file mode 100644 index 000000000..7a409d523 --- /dev/null +++ b/rma_sale_auto_detect/views/rma.xml @@ -0,0 +1,39 @@ + + + + + rma + + + + + + + +