diff --git a/pos_sale_picking_keep/README.rst b/pos_sale_picking_keep/README.rst index f1d682f12a..c563f084d3 100644 --- a/pos_sale_picking_keep/README.rst +++ b/pos_sale_picking_keep/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - =========================== Keep sale pickings from PoS =========================== @@ -17,7 +13,7 @@ Keep sale pickings from PoS .. |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/license-AGPL--3-blue.png +.. |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%2Fpos-lightgray.png?logo=github @@ -70,6 +66,18 @@ Authors * Tecnativa +Contributors +------------ + +- `Tecnativa `__ + + - Pedro M. Baeza + - Víctor Martínez + +- `ACSONE SA/NV `__: + + - Denis Roussel + Maintainers ----------- diff --git a/pos_sale_picking_keep/__manifest__.py b/pos_sale_picking_keep/__manifest__.py index f4cb506b9a..fdc90ac33f 100644 --- a/pos_sale_picking_keep/__manifest__.py +++ b/pos_sale_picking_keep/__manifest__.py @@ -10,6 +10,7 @@ "license": "AGPL-3", "installable": True, "depends": ["pos_sale"], + "data": ["views/res_config_settings.xml"], "assets": { "web.assets_tests": [ "pos_sale_picking_keep/static/tests/tours/**/*", diff --git a/pos_sale_picking_keep/models/__init__.py b/pos_sale_picking_keep/models/__init__.py index 1315735d03..ed8a4e604f 100644 --- a/pos_sale_picking_keep/models/__init__.py +++ b/pos_sale_picking_keep/models/__init__.py @@ -2,3 +2,7 @@ from . import pos_order from . import pos_session from . import sale_order_line +from . import pos_config +from . import pos_order_line +from . import stock_picking +from . import res_config_settings diff --git a/pos_sale_picking_keep/models/pos_config.py b/pos_sale_picking_keep/models/pos_config.py new file mode 100644 index 0000000000..e459473749 --- /dev/null +++ b/pos_sale_picking_keep/models/pos_config.py @@ -0,0 +1,12 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class PosConfig(models.Model): + _inherit = "pos.config" + + keep_picking = fields.Boolean( + help="When loading sale orders in POS, Odoo cancels the sale pickings." + "Change the strategy here.", + ) diff --git a/pos_sale_picking_keep/models/pos_order.py b/pos_sale_picking_keep/models/pos_order.py index 4c40b68a6d..c5b3c8f563 100644 --- a/pos_sale_picking_keep/models/pos_order.py +++ b/pos_sale_picking_keep/models/pos_order.py @@ -1,7 +1,6 @@ # Copyright 2025 Tecnativa - Pedro M. Baeza # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo import api, models -from odoo.tools import config class PosOrder(models.Model): @@ -27,20 +26,16 @@ def sync_from_ui(self, orders): # Fake the pickings state before calling super for avoiding the move quantity # reduction that is done upstream that effectively cancels the SO pickings pickings = so_lines.move_ids.picking_id - pickings.state = "draft" - res = super().sync_from_ui(orders) - pickings._compute_state() + state_field = self.env["stock.picking"]._fields["state"] + picking_values = {} + # Save picking state values + for picking in pickings: + picking_values[picking.id] = picking.state + # Don't mark the concerned pickings state as dirty to avoid + # unwanted recomputations + with self.env.protecting([state_field], pickings): + pickings.state = "draft" + res = super().sync_from_ui(orders) + for picking in pickings: + picking.state = picking_values[picking.id] return res - - def _create_order_picking(self): - # Nullify the creation of the pickings at this level - # We cannot use self.env.context.get("test_pos_sale_picking_keep") because - # the tours that run in the tests do not allow that context to be maintained. - # Therefore, we use self.config_id.name. - if ( - config["test_enable"] - and self.config_id.name != "test_pos_sale_picking_keep" - ): - # For not breaking tests of other modules - return super()._create_order_picking() - return True diff --git a/pos_sale_picking_keep/models/pos_order_line.py b/pos_sale_picking_keep/models/pos_order_line.py new file mode 100644 index 0000000000..c6462463f8 --- /dev/null +++ b/pos_sale_picking_keep/models/pos_order_line.py @@ -0,0 +1,26 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import models + + +class PosOrderLine(models.Model): + _inherit = "pos.order.line" + + def _launch_stock_rule_from_pos_order_lines(self): + """ + Launch stock rules for pos order lines that are not linked to a sale order + line and when the strategy is to keep both pos and sale order pickings + """ + lines_to_launch = self.filtered( + lambda line: not line.order_id.pos_config.keep_picking + or ( + line.order_id.pos_config.picking_keep_strategy + == "keep_sale_pos_pickings" + and not line.sale_order_line_id + ) + ) + if lines_to_launch: + super( + PosOrderLine, lines_to_launch + )._launch_stock_rule_from_pos_order_lines() + return True diff --git a/pos_sale_picking_keep/models/res_config_settings.py b/pos_sale_picking_keep/models/res_config_settings.py new file mode 100644 index 0000000000..35e2dcb4de --- /dev/null +++ b/pos_sale_picking_keep/models/res_config_settings.py @@ -0,0 +1,12 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + pos_keep_picking = fields.Boolean( + related="pos_config_id.keep_picking", + readonly=False, + ) diff --git a/pos_sale_picking_keep/models/sale_order_line.py b/pos_sale_picking_keep/models/sale_order_line.py index f44e3837a2..22ba2c5d38 100644 --- a/pos_sale_picking_keep/models/sale_order_line.py +++ b/pos_sale_picking_keep/models/sale_order_line.py @@ -1,19 +1,42 @@ # Copyright 2026 Tecnativa - Víctor Martínez # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import api, models +from odoo import models class SaleOrderLine(models.Model): _inherit = "sale.order.line" - # TODO: Delete if merged https://github.com/odoo/odoo/pull/253333 def _compute_qty_delivered(self): - self = self.with_context(from_qty_delivered=True) - return super()._compute_qty_delivered() + res = super()._compute_qty_delivered() - # TODO: Delete if merged https://github.com/odoo/odoo/pull/253333 - @api.model - def _convert_qty(self, sale_line, qty, direction): - if self.env.context.get("from_qty_delivered"): - return 0 - return super()._convert_qty(sale_line=sale_line, qty=qty, direction=direction) + # Mimic what is done at pos_sale level but lowering the quantity + # TODO: Delete if merged https://github.com/odoo/odoo/pull/253333 + def update_qty_delivered_from_pickings(sale_line, pos_lines): + if all( + picking.state == "done" for picking in pos_lines.order_id.picking_ids + ): + sale_line.qty_delivered -= sum( + ( + self._convert_qty(sale_line, pos_line.qty, "p2s") + for pos_line in pos_lines + if sale_line.product_id.type != "service" + ), + 0, + ) + + for sale_line in self: + if sale_line.pos_order_line_ids.order_id.config_id.keep_picking: + pos_lines = sale_line.pos_order_line_ids.filtered( + lambda order_line: order_line.order_id.state + not in ["cancel", "draft"] + ) + update_qty_delivered_from_pickings(sale_line, pos_lines) + + refund_lines = ( + sale_line.pos_order_line_ids.refund_orderline_ids.filtered( + lambda order_line: order_line.order_id.state + not in ["cancel", "draft"] + ) + ) + update_qty_delivered_from_pickings(sale_line, refund_lines) + return res diff --git a/pos_sale_picking_keep/models/stock_picking.py b/pos_sale_picking_keep/models/stock_picking.py new file mode 100644 index 0000000000..5b4fbea475 --- /dev/null +++ b/pos_sale_picking_keep/models/stock_picking.py @@ -0,0 +1,27 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import api, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + @api.model + def _create_picking_from_pos_order_lines( + self, location_dest_id, lines, picking_type, partner=False + ): + """ + Avoid cancelling existing pickings and re-launching stock rules from + POS line if linked to a sale order line and with a strategy + to keep sale pickings. + """ + lines_without_create = lines.filtered( + lambda line: line.sale_order_line_id + and line.order_id.config_id.keep_picking + ) + return super()._create_picking_from_pos_order_lines( + location_dest_id=location_dest_id, + lines=(lines - lines_without_create), + picking_type=picking_type, + partner=partner, + ) diff --git a/pos_sale_picking_keep/readme/CONTRIBUTORS.md b/pos_sale_picking_keep/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..c0fea7d6e8 --- /dev/null +++ b/pos_sale_picking_keep/readme/CONTRIBUTORS.md @@ -0,0 +1,6 @@ +- [Tecnativa](https://www.tecnativa.com) + - Pedro M. Baeza + - Víctor Martínez + +- [ACSONE SA/NV](https://acsone.eu): + - Denis Roussel \<\> \ No newline at end of file diff --git a/pos_sale_picking_keep/static/description/index.html b/pos_sale_picking_keep/static/description/index.html index 61487a9e41..25d7e2c4bb 100644 --- a/pos_sale_picking_keep/static/description/index.html +++ b/pos_sale_picking_keep/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Keep sale pickings from PoS -
+
+

Keep sale pickings from PoS

- - -Odoo Community Association - -
-

Keep sale pickings from PoS

-

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

+

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

This module inhibits the manipulation that the point of sale mades over the sales orders pickings, and the creation of new pickings under the PoS picking type.

@@ -385,13 +380,14 @@

Keep sale pickings from PoS

  • Bug Tracker
  • Credits
  • -

    Use Cases / Context

    +

    Use Cases / Context

    In some scenarios, you may not want that the point of sale (PoS) handles the pickings of the products you are paying:

      @@ -401,7 +397,7 @@

      Use Cases / Context

      In that cases, it’s better to keep the original sales pickings.

    -

    Bug Tracker

    +

    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 @@ -409,15 +405,29 @@

    Bug Tracker

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

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • Tecnativa
    +
    +

    Contributors

    + +
    -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    Odoo Community Association @@ -432,6 +442,5 @@

    Maintainers

    -
    diff --git a/pos_sale_picking_keep/static/tests/tours/pos_sale_picking_keep.esm.js b/pos_sale_picking_keep/static/tests/tours/pos_sale_picking_keep.esm.js index 63ef17f323..2f3fc360c4 100644 --- a/pos_sale_picking_keep/static/tests/tours/pos_sale_picking_keep.esm.js +++ b/pos_sale_picking_keep/static/tests/tours/pos_sale_picking_keep.esm.js @@ -30,3 +30,18 @@ registry.category("web_tour.tours").add("PosSalePickingKeep2", { PaymentScreen.clickValidate(), ].flat(), }); + +registry.category("web_tour.tours").add("PosSalePickingKeepMixed", { + steps: () => + [ + Chrome.startPoS(), + Dialog.confirm("Open Register"), + PosSale.settleNthOrder(1), + ProductScreen.selectedOrderlineHas("Test Product", "1.00"), + ProductScreen.addOrderline("Test Product 2"), + ProductScreen.clickPayButton(), + PaymentScreen.clickPaymentMethod("Bank", true, {remaining: "0.0"}), + PaymentScreen.clickValidate(), + ReceiptScreen.isShown(), + ].flat(), +}); diff --git a/pos_sale_picking_keep/tests/__init__.py b/pos_sale_picking_keep/tests/__init__.py index bdfa733c4b..054f42ef8c 100644 --- a/pos_sale_picking_keep/tests/__init__.py +++ b/pos_sale_picking_keep/tests/__init__.py @@ -1 +1,2 @@ from . import test_pos_sale_picking_keep +from . import test_pos_sale_picking_keep_realtime diff --git a/pos_sale_picking_keep/tests/common.py b/pos_sale_picking_keep/tests/common.py new file mode 100644 index 0000000000..c1de50cbd7 --- /dev/null +++ b/pos_sale_picking_keep/tests/common.py @@ -0,0 +1,34 @@ +# Copyright 2026 Tecnativa - Víctor Martínez +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import odoo.tests + +from odoo.addons.point_of_sale.tests.test_frontend import TestPointOfSaleHttpCommon + + +@odoo.tests.tagged("post_install", "-at_install") +class PosSalePickingKeepCommon(TestPointOfSaleHttpCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.main_pos_config.keep_picking = True + cls.customer = cls.env["res.partner"].create({"name": "Test partner"}) + cls.warehouse = cls.env["stock.warehouse"].search( + [("company_id", "=", cls.env.company.id)], limit=1 + ) + cls.product = cls.env["product.product"].create( + { + "name": "Test Product", + "available_in_pos": True, + "is_storable": True, + "lst_price": 10.0, + } + ) + cls.product_2 = cls.env["product.product"].create( + { + "name": "Test Product 2", + "available_in_pos": True, + "is_storable": True, + "lst_price": 10.0, + } + ) + cls.main_pos_config.name = "test_pos_sale_picking_keep" diff --git a/pos_sale_picking_keep/tests/test_pos_sale_picking_keep.py b/pos_sale_picking_keep/tests/test_pos_sale_picking_keep.py index b85b122233..1823c6328f 100644 --- a/pos_sale_picking_keep/tests/test_pos_sale_picking_keep.py +++ b/pos_sale_picking_keep/tests/test_pos_sale_picking_keep.py @@ -1,30 +1,15 @@ # Copyright 2026 Tecnativa - Víctor Martínez # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -import odoo.tests from odoo.tests import Form -from odoo.addons.point_of_sale.tests.test_frontend import TestPointOfSaleHttpCommon +from .common import PosSalePickingKeepCommon -@odoo.tests.tagged("post_install", "-at_install") -class TestPosSalePickingKeep(TestPointOfSaleHttpCommon): +class TestPosSalePickingKeep(PosSalePickingKeepCommon): @classmethod def setUpClass(cls): super().setUpClass() cls.env.company.point_of_sale_update_stock_quantities = "closing" - cls.customer = cls.env["res.partner"].create({"name": "Test partner"}) - cls.warehouse = cls.env["stock.warehouse"].search( - [("company_id", "=", cls.env.company.id)], limit=1 - ) - cls.product = cls.env["product.product"].create( - { - "name": "Test Product", - "available_in_pos": True, - "is_storable": True, - "lst_price": 10.0, - } - ) - cls.main_pos_config.name = "test_pos_sale_picking_keep" def test_sale_order_pos_order_done(self): self.env["stock.quant"]._update_available_quantity( diff --git a/pos_sale_picking_keep/tests/test_pos_sale_picking_keep_realtime.py b/pos_sale_picking_keep/tests/test_pos_sale_picking_keep_realtime.py new file mode 100644 index 0000000000..3242f538b0 --- /dev/null +++ b/pos_sale_picking_keep/tests/test_pos_sale_picking_keep_realtime.py @@ -0,0 +1,79 @@ +# Copyright 2026 Tecnativa - Víctor Martínez +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.tests import Form + +from .common import PosSalePickingKeepCommon + + +class TestPosSalePickingKeepRealtime(PosSalePickingKeepCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.company.point_of_sale_update_stock_quantities = "real" + + def test_sale_order_pos_order_done(self): + self.env["stock.quant"]._update_available_quantity( + self.product, self.warehouse.lot_stock_id, 1 + ) + self.env["stock.quant"]._update_available_quantity( + self.product_2, self.warehouse.lot_stock_id, 10 + ) + order_form = Form(self.env["sale.order"]) + order_form.partner_id = self.customer + order_form.client_order_ref = "test_pos_sale_picking_keep" + with order_form.order_line.new() as line_form: + line_form.product_id = self.product + sale_order = order_form.save() + sol = sale_order.order_line + self.assertEqual(sol.qty_delivered, 0) + self.main_pos_config.open_ui() + self.start_tour( + "/pos/ui?config_id=%d" % self.main_pos_config.id, + "PosSalePickingKeepMixed", + login="accountman", + ) + self.assertEqual(sale_order.state, "sale") + self.assertEqual(len(sale_order.picking_ids), 1) + pos_order = sol.pos_order_line_ids.order_id + self.assertEqual(pos_order.state, "paid") + self.assertTrue(pos_order.picking_ids) + so_picking = sale_order.picking_ids + self.assertEqual(so_picking.state, "assigned") + self.assertEqual(sol.qty_delivered, 0) + sale_order.picking_ids.button_validate() + self.assertEqual(so_picking.state, "done") + self.assertEqual(sol.qty_delivered, 1) + + def test_sale_order_pos_order_done_and_pos_picking(self): + self.env["stock.quant"]._update_available_quantity( + self.product, self.warehouse.lot_stock_id, 1 + ) + self.env["stock.quant"]._update_available_quantity( + self.product_2, self.warehouse.lot_stock_id, 10 + ) + order_form = Form(self.env["sale.order"]) + order_form.partner_id = self.customer + order_form.client_order_ref = "test_pos_sale_picking_keep" + with order_form.order_line.new() as line_form: + line_form.product_id = self.product + sale_order = order_form.save() + sol = sale_order.order_line + self.assertEqual(sol.qty_delivered, 0) + self.main_pos_config.open_ui() + self.start_tour( + "/pos/ui?config_id=%d" % self.main_pos_config.id, + "PosSalePickingKeepMixed", + login="accountman", + ) + self.assertEqual(sale_order.state, "sale") + self.assertEqual(len(sale_order.picking_ids), 1) + pos_order = sol.pos_order_line_ids.order_id + self.assertEqual(pos_order.state, "paid") + self.assertTrue(pos_order.picking_ids) + self.assertEqual(pos_order.picking_ids.state, "done") + so_picking = sale_order.picking_ids + self.assertEqual(so_picking.state, "assigned") + self.assertEqual(sol.qty_delivered, 0) + sale_order.picking_ids.button_validate() + self.assertEqual(so_picking.state, "done") + self.assertEqual(sol.qty_delivered, 1) diff --git a/pos_sale_picking_keep/views/res_config_settings.xml b/pos_sale_picking_keep/views/res_config_settings.xml new file mode 100644 index 0000000000..d6344e0775 --- /dev/null +++ b/pos_sale_picking_keep/views/res_config_settings.xml @@ -0,0 +1,24 @@ + + + + + res.config.settings.view.form.inherit.pos_sale_picking_keep + res.config.settings + + + + + + + + + + +