From 4f8c3f5ec491bd1c0e4feca8770ffdef21d617de Mon Sep 17 00:00:00 2001 From: sbejaoui Date: Mon, 2 Sep 2024 13:10:29 +0200 Subject: [PATCH] [IMP] stock_picking_return_lot: Add a lot field to the return wizard - Add a lot field to the return wizard and create a return line for each lot/product combination. - Use Stock Restrict Lot to track the accepted lot on the return picking. --- stock_picking_return_lot/README.rst | 16 +- stock_picking_return_lot/__init__.py | 2 +- stock_picking_return_lot/__manifest__.py | 6 +- .../readme/CONTRIBUTORS.rst | 4 + .../readme/DESCRIPTION.rst | 11 +- .../static/description/index.html | 15 +- .../tests/test_stock_picking_return_lot.py | 252 ++++++++++++------ stock_picking_return_lot/wizard/__init__.py | 1 - .../wizard/stock_picking_return.py | 31 --- stock_picking_return_lot/wizards/__init__.py | 2 + .../wizards/stock_return_picking.py | 97 +++++++ .../wizards/stock_return_picking.xml | 21 ++ .../wizards/stock_return_picking_line.py | 21 ++ 13 files changed, 363 insertions(+), 116 deletions(-) delete mode 100644 stock_picking_return_lot/wizard/__init__.py delete mode 100644 stock_picking_return_lot/wizard/stock_picking_return.py create mode 100644 stock_picking_return_lot/wizards/__init__.py create mode 100644 stock_picking_return_lot/wizards/stock_return_picking.py create mode 100644 stock_picking_return_lot/wizards/stock_return_picking.xml create mode 100644 stock_picking_return_lot/wizards/stock_return_picking_line.py diff --git a/stock_picking_return_lot/README.rst b/stock_picking_return_lot/README.rst index d5fdf0132cc..c0272518cb2 100644 --- a/stock_picking_return_lot/README.rst +++ b/stock_picking_return_lot/README.rst @@ -28,7 +28,16 @@ Stock Picking Return Lot |badge1| |badge2| |badge3| |badge4| |badge5| -This module propagates serial numbers or lots from initial picking to return picking. +When a product is tracked by lot or serial number and is returned by a customer, +it’s crucial to clearly indicate to the user which lot or serial number can be +accepted. This way, we prevent user from receiving a product with a lot or +serial number different from the original delivery. + +This module enhances the return process by creating a separate return line for +each product/lot and automatically pre-filling it with the lot from the original delivery. +It relies on the `Stock Restrict Lot `__ +module to enforce accurate tracking, ensuring that the reception order reflects +the correct lot or serial number that should be received. **Table of contents** @@ -52,6 +61,7 @@ Authors ~~~~~~~ * Camptocamp +* ACSONE SA/NV Contributors ~~~~~~~~~~~~ @@ -67,6 +77,10 @@ Contributors * Foresti Francesco +* `ACSONE SA/NV `__: + + * Souheil Bejaoui + Maintainers ~~~~~~~~~~~ diff --git a/stock_picking_return_lot/__init__.py b/stock_picking_return_lot/__init__.py index 40272379f72..5cb1c49143f 100644 --- a/stock_picking_return_lot/__init__.py +++ b/stock_picking_return_lot/__init__.py @@ -1 +1 @@ -from . import wizard +from . import wizards diff --git a/stock_picking_return_lot/__manifest__.py b/stock_picking_return_lot/__manifest__.py index b4f955cd575..912ac166b45 100644 --- a/stock_picking_return_lot/__manifest__.py +++ b/stock_picking_return_lot/__manifest__.py @@ -1,4 +1,5 @@ # Copyright 2020 Camptocamp +# Copyright 2024 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). { @@ -6,7 +7,8 @@ "summary": "Propagate SN/lots from origin picking to return picking.", "version": "16.0.1.0.0", "license": "AGPL-3", - "author": "Camptocamp, Odoo Community Association (OCA)", + "author": "Camptocamp, ACSONE SA/NV, Odoo Community Association (OCA)", "website": "https://github.com/OCA/stock-logistics-workflow", - "depends": ["stock", "stock_account"], + "depends": ["stock", "stock_restrict_lot"], + "data": ["wizards/stock_return_picking.xml"], } diff --git a/stock_picking_return_lot/readme/CONTRIBUTORS.rst b/stock_picking_return_lot/readme/CONTRIBUTORS.rst index afc9a4ffe29..6f0a4bd87bd 100644 --- a/stock_picking_return_lot/readme/CONTRIBUTORS.rst +++ b/stock_picking_return_lot/readme/CONTRIBUTORS.rst @@ -8,3 +8,7 @@ * `Ooops404 `__: * Foresti Francesco + +* `ACSONE SA/NV `__: + + * Souheil Bejaoui \ No newline at end of file diff --git a/stock_picking_return_lot/readme/DESCRIPTION.rst b/stock_picking_return_lot/readme/DESCRIPTION.rst index 403bfca41de..9a30d3677db 100644 --- a/stock_picking_return_lot/readme/DESCRIPTION.rst +++ b/stock_picking_return_lot/readme/DESCRIPTION.rst @@ -1 +1,10 @@ -This module propagates serial numbers or lots from initial picking to return picking. +When a product is tracked by lot or serial number and is returned by a customer, +it’s crucial to clearly indicate to the user which lot or serial number can be +accepted. This way, we prevent user from receiving a product with a lot or +serial number different from the original delivery. + +This module enhances the return process by creating a separate return line for +each product/lot and automatically pre-filling it with the lot from the original delivery. +It relies on the `Stock Restrict Lot `__ +module to enforce accurate tracking, ensuring that the reception order reflects +the correct lot or serial number that should be received. diff --git a/stock_picking_return_lot/static/description/index.html b/stock_picking_return_lot/static/description/index.html index 0e60bd768b8..282703fa14e 100644 --- a/stock_picking_return_lot/static/description/index.html +++ b/stock_picking_return_lot/static/description/index.html @@ -369,7 +369,15 @@

Stock Picking Return Lot

!! source digest: sha256:a039386ff4a4425200e457d180c9f74b09f1f2c552b1c685f531a1d26c1d4740 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: AGPL-3 OCA/stock-logistics-workflow Translate me on Weblate Try me on Runboat

-

This module propagates serial numbers or lots from initial picking to return picking.

+

When a product is tracked by lot or serial number and is returned by a customer, +it’s crucial to clearly indicate to the user which lot or serial number can be +accepted. This way, we prevent user from receiving a product with a lot or +serial number different from the original delivery.

+

This module enhances the return process by creating a separate return line for +each product/lot and automatically pre-filling it with the lot from the original delivery. +It relies on the Stock Restrict Lot +module to enforce accurate tracking, ensuring that the reception order reflects +the correct lot or serial number that should be received.

Table of contents

@@ -411,6 +420,10 @@

Contributors

  • Foresti Francesco <francesco.foresti@ooops404.com>
  • +
  • ACSONE SA/NV: +
  • diff --git a/stock_picking_return_lot/tests/test_stock_picking_return_lot.py b/stock_picking_return_lot/tests/test_stock_picking_return_lot.py index 246943f75d5..e4e3085739c 100644 --- a/stock_picking_return_lot/tests/test_stock_picking_return_lot.py +++ b/stock_picking_return_lot/tests/test_stock_picking_return_lot.py @@ -1,107 +1,203 @@ # Copyright 2020 Iryna Vyshnevska Camptocamp # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo.tests import common +from odoo import Command +from odoo.tests.common import TransactionCase -class StockPickingReturnLotTest(common.SavepointCase): - def setUp(self): - super().setUp() - self.picking_obj = self.env["stock.picking"] - partner = self.env["res.partner"].create({"name": "Test"}) - product = self.env["product.product"].create( - {"name": "test_product", "type": "product", "tracking": "serial"} - ) - lot_1 = self.env["stock.lot"].create( - {"name": "000001", "product_id": product.id} - ) - lot_2 = self.env["stock.lot"].create( - {"name": "000002", "product_id": product.id} - ) - picking_type_out = self.env.ref("stock.picking_type_out") - stock_location = self.env.ref("stock.stock_location_stock") - customer_location = self.env.ref("stock.stock_location_customers") - self.env["stock.quant"]._update_available_quantity( - product, stock_location, 1, lot_id=lot_1 +class StockPickingReturnLotTest(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.picking_obj = cls.env["stock.picking"] + cls.partner = cls.env["res.partner"].create({"name": "Test"}) + cls.product = cls.env["product.product"].create( + {"name": "test_product", "type": "product", "tracking": "lot"} ) - self.env["stock.quant"]._update_available_quantity( - product, stock_location, 1, lot_id=lot_2 + cls.lot_1 = cls.env["stock.lot"].create( + {"name": "000001", "product_id": cls.product.id} + ) + cls.lot_2 = cls.env["stock.lot"].create( + {"name": "000002", "product_id": cls.product.id} + ) + cls.picking_type_out = cls.env.ref("stock.picking_type_out") + cls.stock_location = cls.env.ref("stock.stock_location_stock") + cls.customer_location = cls.env.ref("stock.stock_location_customers") + cls.env["stock.quant"]._update_available_quantity( + cls.product, cls.stock_location, 1, lot_id=cls.lot_1 ) - self.picking = self.picking_obj.create( + cls.env["stock.quant"]._update_available_quantity( + cls.product, cls.stock_location, 2, lot_id=cls.lot_2 + ) + cls.picking = cls.picking_obj.create( { - "partner_id": partner.id, - "picking_type_id": picking_type_out.id, - "location_id": stock_location.id, - "location_dest_id": customer_location.id, - "move_lines": [ - ( - 0, - 0, + "partner_id": cls.partner.id, + "picking_type_id": cls.picking_type_out.id, + "location_id": cls.stock_location.id, + "location_dest_id": cls.customer_location.id, + "move_ids": [ + Command.create( { - "name": product.name, - "product_id": product.id, - "product_uom_qty": 2, - "product_uom": product.uom_id.id, - "location_id": stock_location.id, - "location_dest_id": customer_location.id, + "name": cls.product.name, + "product_id": cls.product.id, + "product_uom_qty": 3, + "product_uom": cls.product.uom_id.id, + "location_id": cls.stock_location.id, + "location_dest_id": cls.customer_location.id, }, ) ], } ) - self.picking.move_lines[0].write( + cls.picking.action_confirm() + cls.picking.action_assign() + cls.picking.action_set_quantities_to_reservation() + cls.picking._action_done() + + @classmethod + def create_return_wiz(cls, picking): + return ( + cls.env["stock.return.picking"] + .with_context(active_id=picking.id, active_model="stock.picking") + .create({}) + ) + + def _create_validate_picking(self): + picking = self.picking_obj.create( { - "move_line_ids": [ - ( - 0, - 0, - { - "product_id": product.id, - "qty_done": 1.0, - "lot_id": lot_1.id, - "product_uom_id": product.uom_id.id, - "location_id": stock_location.id, - "location_dest_id": customer_location.id, - }, - ), - ( - 0, - 0, + "partner_id": self.partner.id, + "picking_type_id": self.picking_type_out.id, + "location_id": self.stock_location.id, + "location_dest_id": self.customer_location.id, + "move_ids": [ + Command.create( { - "product_id": product.id, - "qty_done": 1.0, - "lot_id": lot_2.id, - "product_uom_id": product.uom_id.id, - "location_id": stock_location.id, - "location_dest_id": customer_location.id, - "picking_id": self.picking.id, + "name": self.product.name, + "product_id": self.product.id, + "product_uom_qty": 1, + "product_uom": self.product.uom_id.id, + "location_id": self.stock_location.id, + "location_dest_id": self.customer_location.id, }, ), - ] + ], + } + ) + self.env["stock.move"].create( + { + "picking_id": picking.id, + "name": self.product.name, + "product_id": self.product.id, + "product_uom_qty": 1, + "product_uom": self.product.uom_id.id, + "location_id": self.stock_location.id, + "location_dest_id": self.customer_location.id, } ) - self.picking.action_confirm() - self.picking.action_assign() - self.picking.move_lines._action_done() + picking.action_confirm() + picking.action_assign() + picking.action_set_quantities_to_reservation() + picking._action_done() + return picking - def create_return_wiz(self): - return ( - self.env["stock.return.picking"] - .with_context(active_id=self.picking.id, active_model="stock.picking") - .create({}) + def test_partial_return(self): + wiz = self.create_return_wiz(self.picking) + wiz._onchange_picking_id() + self.assertEqual(len(wiz.product_return_moves), 2) + return_line_1 = wiz.product_return_moves.filtered( + lambda m, lot=self.lot_1: m.lot_id == lot + ) + return_line_2 = wiz.product_return_moves.filtered( + lambda m, lot=self.lot_2: m.lot_id == lot + ) + self.assertEqual(return_line_1.quantity, 1) + self.assertEqual(return_line_2.quantity, 2) + return_line_2.quantity = 1 + picking_returned_id = wiz._create_returns()[0] + picking_returned = self.picking_obj.browse(picking_returned_id) + move_1 = picking_returned.move_ids.filtered( + lambda m, lot=self.lot_1: m.restrict_lot_id == lot + ) + move_2 = picking_returned.move_ids.filtered( + lambda m, lot=self.lot_2: m.restrict_lot_id == lot ) + self.assertEqual(move_1.move_line_ids.lot_id, self.lot_1) + self.assertEqual(move_2.move_line_ids.lot_id, self.lot_2) + self.assertEqual(move_2.product_qty, 1) - def test_return(self): - wiz = self.create_return_wiz() + def test_full_return_after_partial_return(self): + self.test_partial_return() + wiz = self.create_return_wiz(self.picking) wiz._onchange_picking_id() - self.assertEqual(len(wiz.product_return_moves), 1) + self.assertEqual(len(wiz.product_return_moves), 2) + + return_line_1 = wiz.product_return_moves.filtered( + lambda m, lot=self.lot_1: m.lot_id == lot + ) + return_line_2 = wiz.product_return_moves.filtered( + lambda m, lot=self.lot_2: m.lot_id == lot + ) + self.assertEqual(return_line_1.quantity, 0) + self.assertEqual(return_line_2.quantity, 1) + picking_returned_id = wiz._create_returns()[0] + picking_returned = self.picking_obj.browse(picking_returned_id) + move_1 = picking_returned.move_ids.filtered( + lambda m, lot=self.lot_1: m.restrict_lot_id == lot + ) + move_2 = picking_returned.move_ids.filtered( + lambda m, lot=self.lot_2: m.restrict_lot_id == lot + ) + self.assertFalse(move_1) + self.assertEqual(move_2.move_line_ids.lot_id, self.lot_2) + self.assertEqual(move_2.product_qty, 1) + + def test_multiple_move_same_product_different_lot(self): + self.env["stock.quant"]._update_available_quantity( + self.product, self.stock_location, 1, lot_id=self.lot_1 + ) + self.env["stock.quant"]._update_available_quantity( + self.product, self.stock_location, 1, lot_id=self.lot_2 + ) + picking = self._create_validate_picking() + wiz = self.create_return_wiz(picking) + wiz._onchange_picking_id() + self.assertEqual(len(wiz.product_return_moves), 2) + return_line_1 = wiz.product_return_moves.filtered( + lambda m, lot=self.lot_1: m.lot_id == lot + ) + return_line_2 = wiz.product_return_moves.filtered( + lambda m, lot=self.lot_2: m.lot_id == lot + ) + self.assertEqual(return_line_1.quantity, 1) + self.assertEqual(return_line_2.quantity, 1) picking_returned_id = wiz._create_returns()[0] picking_returned = self.picking_obj.browse(picking_returned_id) + move_1 = picking_returned.move_ids.filtered( + lambda m, lot=self.lot_1: m.restrict_lot_id == lot + ) + move_2 = picking_returned.move_ids.filtered( + lambda m, lot=self.lot_2: m.restrict_lot_id == lot + ) + self.assertEqual(move_1.move_line_ids.lot_id, self.lot_1) + self.assertEqual(move_2.move_line_ids.lot_id, self.lot_2) + self.assertEqual(move_2.product_qty, 1) - self.assertEqual(len(picking_returned.move_line_ids), 2) - self.assertTrue( - picking_returned.move_line_ids.filtered(lambda l: l.lot_id.name == "000002") + def test_multiple_move_same_product_same_lot(self): + self.env["stock.quant"]._update_available_quantity( + self.product, self.stock_location, 2, lot_id=self.lot_1 + ) + picking = self._create_validate_picking() + wiz = self.create_return_wiz(picking) + wiz._onchange_picking_id() + self.assertEqual(len(wiz.product_return_moves), 1) + return_line_1 = wiz.product_return_moves.filtered( + lambda m, lot=self.lot_1: m.lot_id == lot ) - self.assertTrue( - picking_returned.move_line_ids.filtered(lambda l: l.lot_id.name == "000001") + self.assertEqual(return_line_1.quantity, 2) + picking_returned_id = wiz._create_returns()[0] + picking_returned = self.picking_obj.browse(picking_returned_id) + move_1 = picking_returned.move_ids.filtered( + lambda m, lot=self.lot_1: m.restrict_lot_id == lot ) + self.assertEqual(move_1.move_line_ids.lot_id, self.lot_1) + self.assertEqual(move_1.product_qty, 2) diff --git a/stock_picking_return_lot/wizard/__init__.py b/stock_picking_return_lot/wizard/__init__.py deleted file mode 100644 index ad0b47c23ff..00000000000 --- a/stock_picking_return_lot/wizard/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import stock_picking_return diff --git a/stock_picking_return_lot/wizard/stock_picking_return.py b/stock_picking_return_lot/wizard/stock_picking_return.py deleted file mode 100644 index 6240270e371..00000000000 --- a/stock_picking_return_lot/wizard/stock_picking_return.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright 2020 Iryna Vyshnevska Camptocamp -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). - -from odoo import fields, models - - -class ReturnPicking(models.TransientModel): - _inherit = "stock.return.picking" - - def _create_returns(self): - # return wizard cannot hold few lines for one move the implementation of - # stock_account will raise singltone error, to propagate lot_id we are - # mapping moves between pickings after move was created - res = super()._create_returns() - picking_returned = self.env["stock.picking"].browse(res[0]) - ml_ids_to_update = picking_returned.move_line_ids.ids - moves_with_lot = self.product_return_moves.mapped( - "move_id.move_line_ids" - ).filtered(lambda l: l.lot_id) - - for line in moves_with_lot: - ml = fields.first( - picking_returned.move_line_ids.filtered( - lambda l: l.product_id == line.product_id - and l.id in ml_ids_to_update - ) - ) - if ml and not ml.lot_id and (ml.product_uom_qty == line.qty_done): - ml.lot_id = line.lot_id - ml_ids_to_update.remove(ml.id) - return res diff --git a/stock_picking_return_lot/wizards/__init__.py b/stock_picking_return_lot/wizards/__init__.py new file mode 100644 index 00000000000..18b78728aae --- /dev/null +++ b/stock_picking_return_lot/wizards/__init__.py @@ -0,0 +1,2 @@ +from . import stock_return_picking +from . import stock_return_picking_line diff --git a/stock_picking_return_lot/wizards/stock_return_picking.py b/stock_picking_return_lot/wizards/stock_return_picking.py new file mode 100644 index 00000000000..ce744748954 --- /dev/null +++ b/stock_picking_return_lot/wizards/stock_return_picking.py @@ -0,0 +1,97 @@ +# Copyright 2020 Iryna Vyshnevska Camptocamp +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from collections import defaultdict + +from odoo import api, models +from odoo.tools.float_utils import float_round + + +class ReturnPicking(models.TransientModel): + _inherit = "stock.return.picking" + + def _get_qty_done_by_product_lot(self): + res = defaultdict(float) + for group in self.env["stock.move.line"].read_group( + [ + ("picking_id", "=", self.picking_id.id), + ("state", "=", "done"), + ("move_id.scrapped", "=", False), + ], + ["qty_done:sum"], + ["product_id", "lot_id"], + lazy=False, + ): + lot_id = group.get("lot_id")[0] if group.get("lot_id") else False + product_id = group.get("product_id")[0] + qty_done = group.get("qty_done") + res[(product_id, lot_id)] += qty_done + return res + + @api.onchange("picking_id") + def _onchange_picking_id(self): + res = super()._onchange_picking_id() + product_return_moves = [(5,)] + line_fields = [f for f in self.env["stock.return.picking.line"]._fields.keys()] + product_return_moves_data_tmpl = self.env[ + "stock.return.picking.line" + ].default_get(line_fields) + qty_done_by_product_lot = self._get_qty_done_by_product_lot() + for (product_id, lot_id), qty_done in qty_done_by_product_lot.items(): + product_return_moves_data = dict(product_return_moves_data_tmpl) + product_return_moves_data.update( + self._prepare_stock_return_picking_line_vals( + product_id, lot_id, qty_done + ) + ) + product_return_moves.append((0, 0, product_return_moves_data)) + if self.picking_id: + self.product_return_moves = product_return_moves + return res + + @api.model + def _prepare_stock_return_picking_line_vals(self, product_id, lot_id, qty_done): + moves = self.picking_id.move_line_ids.filtered( + lambda ml, p_id=product_id, l_id=lot_id: ml.product_id.id == p_id + and ml.lot_id.id == l_id + ).move_id + quantity = qty_done + for dest_move in moves.move_dest_ids: + if ( + not dest_move.origin_returned_move_id + or dest_move.origin_returned_move_id not in moves + ): + continue + + if ( + dest_move.restrict_lot_id + and dest_move.restrict_lot_id.id == lot_id + or not lot_id + ): + if dest_move.state in ("partially_available", "assigned"): + quantity -= sum(dest_move.move_line_ids.mapped("reserved_qty")) + elif dest_move.state == "done": + quantity -= dest_move.product_qty + quantity = float_round( + quantity, precision_rounding=moves.product_id.uom_id.rounding + ) + return { + "product_id": moves.product_id.id, + "quantity": quantity, + "move_id": moves[0].id, + "uom_id": moves.product_id.uom_id.id, + "lot_id": lot_id, + } + + def _prepare_move_default_values(self, return_line, new_picking): + vals = super()._prepare_move_default_values(return_line, new_picking) + vals["restrict_lot_id"] = return_line.lot_id.id + return vals + + def _create_returns(self): + res = super()._create_returns() + picking_returned = self.env["stock.picking"].browse(res[0]) + for ml in picking_returned.move_line_ids: + ml.lot_id = ml.move_id.restrict_lot_id + return res diff --git a/stock_picking_return_lot/wizards/stock_return_picking.xml b/stock_picking_return_lot/wizards/stock_return_picking.xml new file mode 100644 index 00000000000..1594f013445 --- /dev/null +++ b/stock_picking_return_lot/wizards/stock_return_picking.xml @@ -0,0 +1,21 @@ + + + + + stock.return.picking + + + + + + + + + diff --git a/stock_picking_return_lot/wizards/stock_return_picking_line.py b/stock_picking_return_lot/wizards/stock_return_picking_line.py new file mode 100644 index 00000000000..fc6e391b60a --- /dev/null +++ b/stock_picking_return_lot/wizards/stock_return_picking_line.py @@ -0,0 +1,21 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class StockReturnPickingLine(models.TransientModel): + + _inherit = "stock.return.picking.line" + + lot_id = fields.Many2one( + "stock.lot", + string="Lot/Serial Number", + domain="[('product_id', '=', product_id)]", + ) + lots_visible = fields.Boolean(compute="_compute_lots_visible") + + @api.depends("product_id.tracking") + def _compute_lots_visible(self): + for rec in self: + rec.lots_visible = rec.product_id.tracking != "none"