From 1976a48edc8ca0e43af67ace19015187d25c4018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 27 May 2024 12:13:05 +0200 Subject: [PATCH 01/11] sale_stock_available_to_promise_release: rework tests.common.Common class to ease extension --- .../tests/common.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/sale_stock_available_to_promise_release/tests/common.py b/sale_stock_available_to_promise_release/tests/common.py index c0c9c0e269..7d70958ff9 100644 --- a/sale_stock_available_to_promise_release/tests/common.py +++ b/sale_stock_available_to_promise_release/tests/common.py @@ -21,12 +21,13 @@ def setUpClassProduct(cls): } ) + @classmethod + def _create_sale_order(cls): + return cls.env["sale.order"].create({"partner_id": cls.customer.id}) + @classmethod def setUpClassSale(cls): - customer = cls.env["res.partner"].create( - {"name": "Partner who loves storable products"} - ) - cls.sale = cls.env["sale.order"].create({"partner_id": customer.id}) + cls.sale = cls._create_sale_order() cls.line = cls.env["sale.order.line"].create( { "order_id": cls.sale.id, @@ -45,6 +46,9 @@ def setUpClassStock(cls): def setUpClass(cls): super().setUpClass() cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.customer = cls.env["res.partner"].create( + {"name": "Partner who loves storable products"} + ) cls.setUpClassProduct() cls.setUpClassSale() cls.setUpClassStock() From b09a14dca34e2fcfa392e29bd3ee4d65b02650c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 27 May 2024 12:15:28 +0200 Subject: [PATCH 02/11] sale_stock_available_to_promise_release_block: add Unblock Release wizard This new wizard allows to give more options to the user regarding the unblocking process, like the scheduled date to set on unblocked moves, and re-assign automatically a stock operation on them (so they could be grouped together in the same transfer). --- .../__init__.py | 2 + .../__manifest__.py | 6 +- .../hooks.py | 18 ++++ .../migrations/16.0.1.1.0/pre-migrate.py | 35 ++++++ .../models/sale_order.py | 7 ++ .../security/ir.model.access.csv | 3 + .../tests/test_sale_block_release.py | 100 ++++++++++++++++++ .../views/sale_order_line.xml | 13 +-- .../views/stock_move.xml | 16 +++ .../wizards/__init__.py | 1 + .../wizards/unblock_release.py | 99 +++++++++++++++++ .../wizards/unblock_release.xml | 28 +++++ 12 files changed, 319 insertions(+), 9 deletions(-) create mode 100644 sale_stock_available_to_promise_release_block/hooks.py create mode 100644 sale_stock_available_to_promise_release_block/migrations/16.0.1.1.0/pre-migrate.py create mode 100644 sale_stock_available_to_promise_release_block/security/ir.model.access.csv create mode 100644 sale_stock_available_to_promise_release_block/views/stock_move.xml create mode 100644 sale_stock_available_to_promise_release_block/wizards/__init__.py create mode 100644 sale_stock_available_to_promise_release_block/wizards/unblock_release.py create mode 100644 sale_stock_available_to_promise_release_block/wizards/unblock_release.xml diff --git a/sale_stock_available_to_promise_release_block/__init__.py b/sale_stock_available_to_promise_release_block/__init__.py index 0650744f6b..a0f653930e 100644 --- a/sale_stock_available_to_promise_release_block/__init__.py +++ b/sale_stock_available_to_promise_release_block/__init__.py @@ -1 +1,3 @@ from . import models +from . import wizards +from .hooks import post_init_hook diff --git a/sale_stock_available_to_promise_release_block/__manifest__.py b/sale_stock_available_to_promise_release_block/__manifest__.py index 1b3b34dfa2..20e32ef4f6 100644 --- a/sale_stock_available_to_promise_release_block/__manifest__.py +++ b/sale_stock_available_to_promise_release_block/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Stock Available to Promise Release - Block from Sales", "summary": """Block release of deliveries from sales orders.""", - "version": "16.0.1.0.0", + "version": "16.0.1.1.0", "license": "AGPL-3", "author": "Camptcamp, ACSONE SA/NV, BCIM, Odoo Community Association (OCA)", "website": "https://github.com/OCA/wms", @@ -15,8 +15,12 @@ "stock_available_to_promise_release_block", ], "data": [ + "security/ir.model.access.csv", "views/sale_order.xml", "views/sale_order_line.xml", + "views/stock_move.xml", + "wizards/unblock_release.xml", ], "installable": True, + "post_init_hook": "post_init_hook", } diff --git a/sale_stock_available_to_promise_release_block/hooks.py b/sale_stock_available_to_promise_release_block/hooks.py new file mode 100644 index 0000000000..e976062791 --- /dev/null +++ b/sale_stock_available_to_promise_release_block/hooks.py @@ -0,0 +1,18 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def post_init_hook(cr, registry): + _logger.info("Remove original 'Unblock Release' server action...") + env = api.Environment(cr, SUPERUSER_ID, {}) + action = env.ref( + "stock_available_to_promise_release_block.action_stock_move_unblock_release", + raise_if_not_found=False, + ) + action.unlink() diff --git a/sale_stock_available_to_promise_release_block/migrations/16.0.1.1.0/pre-migrate.py b/sale_stock_available_to_promise_release_block/migrations/16.0.1.1.0/pre-migrate.py new file mode 100644 index 0000000000..9ec4cdcd26 --- /dev/null +++ b/sale_stock_available_to_promise_release_block/migrations/16.0.1.1.0/pre-migrate.py @@ -0,0 +1,35 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + remove_unblock_release_ir_action_server(cr) + + +def remove_unblock_release_ir_action_server(cr): + # The same XML-ID will be used by a new window action to open a wizard + _logger.info("Remove action 'action_sale_order_line_unblock_release'") + queries = [ + """ + DELETE FROM ir_act_server + WHERE id IN ( + SELECT res_id + FROM ir_model_data + WHERE module='sale_stock_available_to_promise_release_block' + AND name='action_sale_order_line_unblock_release' + AND model='ir.actions.server' + ); + """, + """ + DELETE FROM ir_model_data + WHERE module='sale_stock_available_to_promise_release_block' + AND name='action_sale_order_line_unblock_release'; + """, + ] + for query in queries: + cr.execute(query) diff --git a/sale_stock_available_to_promise_release_block/models/sale_order.py b/sale_stock_available_to_promise_release_block/models/sale_order.py index e9630f9dca..d38774de2e 100644 --- a/sale_stock_available_to_promise_release_block/models/sale_order.py +++ b/sale_stock_available_to_promise_release_block/models/sale_order.py @@ -14,3 +14,10 @@ class SaleOrder(models.Model): states={"draft": [("readonly", False)]}, help="Block the release of the generated delivery at order confirmation.", ) + + def action_open_move_need_release(self): + action = super().action_open_move_need_release() + if not action.get("context"): + action["context"] = {} + action["context"].update(from_sale_order_id=self.id) + return action diff --git a/sale_stock_available_to_promise_release_block/security/ir.model.access.csv b/sale_stock_available_to_promise_release_block/security/ir.model.access.csv new file mode 100644 index 0000000000..e4b605c825 --- /dev/null +++ b/sale_stock_available_to_promise_release_block/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_unblock_release_sale,access.unblock.release,model_unblock_release,sales_team.group_sale_salesman,1,1,1,0 +access_unblock_release_stock,access.unblock.release,model_unblock_release,stock.group_stock_user,1,1,1,0 diff --git a/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py b/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py index 1ef44f80fa..0c7885d400 100644 --- a/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py +++ b/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py @@ -1,10 +1,21 @@ # Copyright 2024 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import psycopg2 + +from odoo import fields +from odoo.tests.common import Form + from odoo.addons.sale_stock_available_to_promise_release.tests import common class TestSaleBlockRelease(common.Common): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Ensure there is no security lead during tests + cls.env.company.security_lead = 0 + def test_sale_release_not_blocked(self): self._set_stock(self.line.product_id, self.line.product_uom_qty) self.assertFalse(self.sale.block_release) @@ -16,3 +27,92 @@ def test_sale_release_blocked(self): self.sale.block_release = True self.sale.action_confirm() self.assertTrue(self.sale.picking_ids.release_blocked) + + def _create_unblock_release_wizard( + self, order_lines, date_deadline=None, from_order=None, option="free" + ): + wiz_form = Form( + self.env["unblock.release"].with_context( + from_sale_order_id=from_order and from_order.id, + active_model=order_lines._name, + active_ids=order_lines.ids, + default_option=option, + ) + ) + if date_deadline: + wiz_form.date_deadline = date_deadline + return wiz_form.save() + + def test_sale_order_line_unblock_release_contextual(self): + self._set_stock(self.line.product_id, self.line.product_uom_qty) + self.sale.block_release = True + self.sale.action_confirm() + # Unblock deliveries through the wizard, opened from another SO + # to define default values + new_sale = self._create_sale_order() + new_sale.commitment_date = fields.Datetime.add(fields.Datetime.now(), days=1) + wiz = self._create_unblock_release_wizard( + self.sale.order_line, from_order=new_sale + ) + self.assertEqual(wiz.option, "contextual") + self.assertEqual(wiz.date_deadline, new_sale.commitment_date) + self.assertNotEqual(wiz.order_line_ids.move_ids.date, new_sale.commitment_date) + old_picking = wiz.order_line_ids.move_ids.picking_id + wiz.validate() + # Deliveries have been scheduled to the new date deadline + new_picking = wiz.order_line_ids.move_ids.picking_id + self.assertEqual(wiz.order_line_ids.move_ids.date, new_sale.commitment_date) + self.assertNotEqual(old_picking, new_picking) + self.assertFalse(old_picking.exists()) + + def test_sale_order_line_unblock_release_free(self): + self._set_stock(self.line.product_id, self.line.product_uom_qty) + self.sale.block_release = True + self.sale.action_confirm() + # Unblock deliveries through the wizard + new_date_deadline = fields.Datetime.add(fields.Datetime.now(), days=1) + wiz = self._create_unblock_release_wizard( + self.sale.order_line, date_deadline=new_date_deadline + ) + self.assertEqual(wiz.date_deadline, new_date_deadline) + self.assertNotEqual(wiz.order_line_ids.move_ids.date, new_date_deadline) + old_picking = wiz.order_line_ids.move_ids.picking_id + wiz.validate() + # Deliveries have been scheduled to the new date deadline + new_picking = wiz.order_line_ids.move_ids.picking_id + self.assertEqual(wiz.order_line_ids.move_ids.date, new_date_deadline) + self.assertNotEqual(old_picking, new_picking) + self.assertFalse(old_picking.exists()) + + def test_sale_order_line_unblock_release_asap(self): + # Start with a blocked SO having a commitment date in the past + self._set_stock(self.line.product_id, self.line.product_uom_qty) + self.sale.block_release = True + yesterday = fields.Datetime.subtract(fields.Datetime.now(), days=1) + self.sale.commitment_date = yesterday + self.sale.action_confirm() + # Unblock deliveries through the wizard + today = fields.Datetime.now() + wiz = self._create_unblock_release_wizard(self.sale.order_line, option="asap") + self.assertEqual(wiz.date_deadline, today) + self.assertNotEqual(wiz.order_line_ids.move_ids.date, today) + old_picking = wiz.order_line_ids.move_ids.picking_id + wiz.validate() + # Deliveries have been scheduled for today + new_picking = wiz.order_line_ids.move_ids.picking_id + self.assertEqual(wiz.order_line_ids.move_ids.date, today) + self.assertNotEqual(old_picking, new_picking) + self.assertFalse(old_picking.exists()) + + def test_sale_order_line_unblock_release_past_date_deadline(self): + self._set_stock(self.line.product_id, self.line.product_uom_qty) + self.sale.block_release = True + self.sale.action_confirm() + # Try to unblock deliveries through the wizard with a scheduled date + # in the past + new_sale = self._create_sale_order() + yesterday = fields.Datetime.subtract(fields.Datetime.now(), days=1) + with self.assertRaises(psycopg2.errors.CheckViolation): + self._create_unblock_release_wizard( + self.sale.order_line, date_deadline=yesterday, from_order=new_sale + ) diff --git a/sale_stock_available_to_promise_release_block/views/sale_order_line.xml b/sale_stock_available_to_promise_release_block/views/sale_order_line.xml index dc5bc18af7..0b018251a4 100644 --- a/sale_stock_available_to_promise_release_block/views/sale_order_line.xml +++ b/sale_stock_available_to_promise_release_block/views/sale_order_line.xml @@ -34,16 +34,13 @@ - + Unblock Release - - + unblock.release + form + new + list - code - - if records: - records.move_ids.action_unblock_release() - diff --git a/sale_stock_available_to_promise_release_block/views/stock_move.xml b/sale_stock_available_to_promise_release_block/views/stock_move.xml new file mode 100644 index 0000000000..1db7ae1abe --- /dev/null +++ b/sale_stock_available_to_promise_release_block/views/stock_move.xml @@ -0,0 +1,16 @@ + + + + + + + Unblock Release + unblock.release + form + new + + list + + + diff --git a/sale_stock_available_to_promise_release_block/wizards/__init__.py b/sale_stock_available_to_promise_release_block/wizards/__init__.py new file mode 100644 index 0000000000..77fc1c0e80 --- /dev/null +++ b/sale_stock_available_to_promise_release_block/wizards/__init__.py @@ -0,0 +1 @@ +from . import unblock_release diff --git a/sale_stock_available_to_promise_release_block/wizards/unblock_release.py b/sale_stock_available_to_promise_release_block/wizards/unblock_release.py new file mode 100644 index 0000000000..6428fe456e --- /dev/null +++ b/sale_stock_available_to_promise_release_block/wizards/unblock_release.py @@ -0,0 +1,99 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import api, fields, models + + +class UnblockRelease(models.TransientModel): + _name = "unblock.release" + _description = "Unblock Release" + + order_line_ids = fields.Many2many( + comodel_name="sale.order.line", + string="Order Lines", + ) + move_ids = fields.Many2many( + comodel_name="stock.move", + string="Delivery moves", + ) + option = fields.Selection( + selection=lambda self: self._selection_option(), + default="asap", + required=True, + ) + date_deadline = fields.Datetime( + compute="_compute_date_deadline", store=True, readonly=False, required=True + ) + + _sql_constraints = [ + ( + "check_scheduled_date", + "CHECK (date_deadline::date >= now()::date)", + "You cannot reschedule deliveries in the past.", + ), + ] + + def _selection_option(self): + options = [ + ("free", "Free"), + ("asap", "As soon as possible"), + ] + if self.env.context.get("from_sale_order_id"): + options.append(("contextual", "From contextual sale order")) + return options + + @api.depends("option") + def _compute_date_deadline(self): + from_sale_order_id = self.env.context.get("from_sale_order_id") + order = self.env["sale.order"].browse(from_sale_order_id).exists() + for rec in self: + rec.date_deadline = False + if rec.option == "asap": + rec.date_deadline = fields.Datetime.now() + elif rec.option == "contextual" and order: + rec.date_deadline = order.commitment_date or order.expected_date + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_model = self.env.context.get("active_model") + active_ids = self.env.context.get("active_ids") + from_sale_order_id = self.env.context.get("from_sale_order_id") + from_sale_order = self.env["sale.order"].browse(from_sale_order_id).exists() + if active_model == "sale.order.line" and active_ids: + res["order_line_ids"] = [(6, 0, active_ids)] + if active_model == "stock.move" and active_ids: + res["move_ids"] = [(6, 0, active_ids)] + if from_sale_order: + res["option"] = "contextual" + return res + + def validate(self): + self.ensure_one() + move_states = ( + "draft", + "waiting", + "confirmed", + "partially_available", + "assigned", + ) + moves = (self.order_line_ids.move_ids or self.move_ids).filtered_domain( + [("state", "in", move_states), ("release_blocked", "=", True)] + ) + # Unset current deliveries (keep track of them to delete empty ones at the end) + pickings = moves.picking_id + moves.picking_id = False + # Update the scheduled date + date_planned = fields.Datetime.subtract( + self.date_deadline, days=self.env.company.security_lead + ) + moves.date = date_planned + # Re-assign deliveries: moves sharing the same criteria - like date - will + # be part of the same delivery. + # NOTE: this will also leverage stock_picking_group_by_partner_by_carrier + # module if this one is installed for instance + moves._assign_picking() + # Unblock release + moves.action_unblock_release() + # Clean up empty deliveries + pickings.filtered(lambda o: not o.move_ids and not o.printed).unlink() diff --git a/sale_stock_available_to_promise_release_block/wizards/unblock_release.xml b/sale_stock_available_to_promise_release_block/wizards/unblock_release.xml new file mode 100644 index 0000000000..e2641330d2 --- /dev/null +++ b/sale_stock_available_to_promise_release_block/wizards/unblock_release.xml @@ -0,0 +1,28 @@ + + + + + + unblock.release.form + unblock.release + +
+ + + + + + +
+
+
+
+
+ +
From d5ab691405232cc7cec03c4a075519aaa6a1e312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 27 May 2024 16:31:13 +0200 Subject: [PATCH 03/11] fixup! sale_stock_available_to_promise_release_block: add Unblock Release wizard --- .../tests/test_sale_block_release.py | 71 +++++++++++++++---- .../wizards/unblock_release.py | 22 +++--- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py b/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py index 0c7885d400..06f89ad9e7 100644 --- a/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py +++ b/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py @@ -1,9 +1,7 @@ # Copyright 2024 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -import psycopg2 - -from odoo import fields +from odoo import exceptions, fields from odoo.tests.common import Form from odoo.addons.sale_stock_available_to_promise_release.tests import common @@ -29,13 +27,13 @@ def test_sale_release_blocked(self): self.assertTrue(self.sale.picking_ids.release_blocked) def _create_unblock_release_wizard( - self, order_lines, date_deadline=None, from_order=None, option="free" + self, records=None, date_deadline=None, from_order=None, option="free" ): wiz_form = Form( self.env["unblock.release"].with_context( from_sale_order_id=from_order and from_order.id, - active_model=order_lines._name, - active_ids=order_lines.ids, + active_model=records._name, + active_ids=records.ids, default_option=option, ) ) @@ -43,7 +41,7 @@ def _create_unblock_release_wizard( wiz_form.date_deadline = date_deadline return wiz_form.save() - def test_sale_order_line_unblock_release_contextual(self): + def test_unblock_release_contextual(self): self._set_stock(self.line.product_id, self.line.product_uom_qty) self.sale.block_release = True self.sale.action_confirm() @@ -65,7 +63,33 @@ def test_sale_order_line_unblock_release_contextual(self): self.assertNotEqual(old_picking, new_picking) self.assertFalse(old_picking.exists()) - def test_sale_order_line_unblock_release_free(self): + def test_unblock_release_contextual_update_date(self): + self._set_stock(self.line.product_id, self.line.product_uom_qty) + self.sale.block_release = True + self.sale.action_confirm() + # Unblock deliveries through the wizard, opened from another SO + # to define default values + update the proposed date + new_sale = self._create_sale_order() + new_sale.commitment_date = fields.Datetime.add(fields.Datetime.now(), days=1) + wiz = self._create_unblock_release_wizard( + self.sale.order_line, from_order=new_sale + ) + self.assertEqual(wiz.option, "contextual") + self.assertEqual(wiz.date_deadline, new_sale.commitment_date) + self.assertNotEqual(wiz.order_line_ids.move_ids.date, new_sale.commitment_date) + old_picking = wiz.order_line_ids.move_ids.picking_id + new_date_deadline = fields.Datetime.add(fields.Datetime.now(), days=2) + wiz.date_deadline = new_date_deadline + wiz.validate() + # Deliveries have been scheduled to the new date deadline + new_picking = wiz.order_line_ids.move_ids.picking_id + self.assertEqual(wiz.order_line_ids.move_ids.date, new_sale.commitment_date) + self.assertNotEqual(old_picking, new_picking) + self.assertFalse(old_picking.exists()) + # Commitment date on contextual order has been updated too + self.assertEqual(new_sale.commitment_date, new_date_deadline) + + def test_unblock_release_free(self): self._set_stock(self.line.product_id, self.line.product_uom_qty) self.sale.block_release = True self.sale.action_confirm() @@ -84,7 +108,7 @@ def test_sale_order_line_unblock_release_free(self): self.assertNotEqual(old_picking, new_picking) self.assertFalse(old_picking.exists()) - def test_sale_order_line_unblock_release_asap(self): + def test_unblock_release_asap(self): # Start with a blocked SO having a commitment date in the past self._set_stock(self.line.product_id, self.line.product_uom_qty) self.sale.block_release = True @@ -92,8 +116,8 @@ def test_sale_order_line_unblock_release_asap(self): self.sale.commitment_date = yesterday self.sale.action_confirm() # Unblock deliveries through the wizard - today = fields.Datetime.now() wiz = self._create_unblock_release_wizard(self.sale.order_line, option="asap") + today = wiz.date_deadline self.assertEqual(wiz.date_deadline, today) self.assertNotEqual(wiz.order_line_ids.move_ids.date, today) old_picking = wiz.order_line_ids.move_ids.picking_id @@ -104,7 +128,30 @@ def test_sale_order_line_unblock_release_asap(self): self.assertNotEqual(old_picking, new_picking) self.assertFalse(old_picking.exists()) - def test_sale_order_line_unblock_release_past_date_deadline(self): + def test_unblock_release_asap_from_moves(self): + # Same test than above but running the wizard from moves. + # Start with a blocked SO having a commitment date in the past + self._set_stock(self.line.product_id, self.line.product_uom_qty) + self.sale.block_release = True + yesterday = fields.Datetime.subtract(fields.Datetime.now(), days=1) + self.sale.commitment_date = yesterday + self.sale.action_confirm() + # Unblock deliveries through the wizard + today = fields.Datetime.now() + wiz = self._create_unblock_release_wizard( + self.sale.order_line.move_ids, option="asap" + ) + self.assertEqual(wiz.date_deadline, today) + self.assertNotEqual(wiz.move_ids.date, today) + old_picking = wiz.move_ids.picking_id + wiz.validate() + # Deliveries have been scheduled for today + new_picking = wiz.move_ids.picking_id + self.assertEqual(wiz.move_ids.date, today) + self.assertNotEqual(old_picking, new_picking) + self.assertFalse(old_picking.exists()) + + def test_unblock_release_past_date_deadline(self): self._set_stock(self.line.product_id, self.line.product_uom_qty) self.sale.block_release = True self.sale.action_confirm() @@ -112,7 +159,7 @@ def test_sale_order_line_unblock_release_past_date_deadline(self): # in the past new_sale = self._create_sale_order() yesterday = fields.Datetime.subtract(fields.Datetime.now(), days=1) - with self.assertRaises(psycopg2.errors.CheckViolation): + with self.assertRaises(exceptions.ValidationError): self._create_unblock_release_wizard( self.sale.order_line, date_deadline=yesterday, from_order=new_sale ) diff --git a/sale_stock_available_to_promise_release_block/wizards/unblock_release.py b/sale_stock_available_to_promise_release_block/wizards/unblock_release.py index 6428fe456e..667f0bd1ea 100644 --- a/sale_stock_available_to_promise_release_block/wizards/unblock_release.py +++ b/sale_stock_available_to_promise_release_block/wizards/unblock_release.py @@ -1,7 +1,7 @@ # Copyright 2024 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -from odoo import api, fields, models +from odoo import _, api, exceptions, fields, models class UnblockRelease(models.TransientModel): @@ -25,13 +25,14 @@ class UnblockRelease(models.TransientModel): compute="_compute_date_deadline", store=True, readonly=False, required=True ) - _sql_constraints = [ - ( - "check_scheduled_date", - "CHECK (date_deadline::date >= now()::date)", - "You cannot reschedule deliveries in the past.", - ), - ] + @api.constrains("date_deadline") + def _constrains_date_deadline(self): + today = fields.Date.today() + for rec in self: + if rec.date_deadline.date() < today: + raise exceptions.ValidationError( + _("You cannot reschedule deliveries in the past.") + ) def _selection_option(self): options = [ @@ -97,3 +98,8 @@ def validate(self): moves.action_unblock_release() # Clean up empty deliveries pickings.filtered(lambda o: not o.move_ids and not o.printed).unlink() + # Update commitment date of contextual sale order if any + from_sale_order_id = self.env.context.get("from_sale_order_id") + from_sale_order = self.env["sale.order"].browse(from_sale_order_id).exists() + if from_sale_order.state in ("draft", "sent"): + from_sale_order.commitment_date = self.date_deadline From f81d237590644f8836c66dc818240006909d321b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 28 May 2024 11:20:25 +0200 Subject: [PATCH 04/11] fixup! fixup! sale_stock_available_to_promise_release_block: add Unblock Release wizard --- .../tests/test_sale_block_release.py | 10 +++---- .../wizards/unblock_release.py | 26 ++++++++++++++++--- .../wizards/unblock_release.xml | 10 ++++++- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py b/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py index 06f89ad9e7..48ef496c55 100644 --- a/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py +++ b/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py @@ -27,7 +27,7 @@ def test_sale_release_blocked(self): self.assertTrue(self.sale.picking_ids.release_blocked) def _create_unblock_release_wizard( - self, records=None, date_deadline=None, from_order=None, option="free" + self, records=None, date_deadline=None, from_order=None, option="manual" ): wiz_form = Form( self.env["unblock.release"].with_context( @@ -108,7 +108,7 @@ def test_unblock_release_free(self): self.assertNotEqual(old_picking, new_picking) self.assertFalse(old_picking.exists()) - def test_unblock_release_asap(self): + def test_unblock_release_automatic(self): # Start with a blocked SO having a commitment date in the past self._set_stock(self.line.product_id, self.line.product_uom_qty) self.sale.block_release = True @@ -116,7 +116,7 @@ def test_unblock_release_asap(self): self.sale.commitment_date = yesterday self.sale.action_confirm() # Unblock deliveries through the wizard - wiz = self._create_unblock_release_wizard(self.sale.order_line, option="asap") + wiz = self._create_unblock_release_wizard(self.sale.order_line, option="automatic") today = wiz.date_deadline self.assertEqual(wiz.date_deadline, today) self.assertNotEqual(wiz.order_line_ids.move_ids.date, today) @@ -128,7 +128,7 @@ def test_unblock_release_asap(self): self.assertNotEqual(old_picking, new_picking) self.assertFalse(old_picking.exists()) - def test_unblock_release_asap_from_moves(self): + def test_unblock_release_automatic_from_moves(self): # Same test than above but running the wizard from moves. # Start with a blocked SO having a commitment date in the past self._set_stock(self.line.product_id, self.line.product_uom_qty) @@ -139,7 +139,7 @@ def test_unblock_release_asap_from_moves(self): # Unblock deliveries through the wizard today = fields.Datetime.now() wiz = self._create_unblock_release_wizard( - self.sale.order_line.move_ids, option="asap" + self.sale.order_line.move_ids, option="automatic" ) self.assertEqual(wiz.date_deadline, today) self.assertNotEqual(wiz.move_ids.date, today) diff --git a/sale_stock_available_to_promise_release_block/wizards/unblock_release.py b/sale_stock_available_to_promise_release_block/wizards/unblock_release.py index 667f0bd1ea..7125ec93a4 100644 --- a/sale_stock_available_to_promise_release_block/wizards/unblock_release.py +++ b/sale_stock_available_to_promise_release_block/wizards/unblock_release.py @@ -18,12 +18,30 @@ class UnblockRelease(models.TransientModel): ) option = fields.Selection( selection=lambda self: self._selection_option(), - default="asap", + default="automatic", required=True, + help=( + "- Manual: schedule blocked deliveries at a given date;\n" + "- Automatic: schedule blocked deliveries as soon as possible;\n" + "- Contextual: schedule blocked deliveries based by default on the " + "commitment date of the contextual sale order. Commitment date of " + "the order will be updated if not yet confirmed.", + ), + # help=( + # "- Manual: schedule blocked deliveries at a given date, in the " + # "chosen release channel;\n" + # "- Automatic: schedule blocked deliveries as soon as possible in the " + # "relevant release channel;\n" + # "- Contextual: schedule blocked deliveries based by default on the " + # "commitment date and release channel (if any) of the contextual " + # "sale order. Commitment date and release channel of the order will " + # "be updated if not yet confirmed.\n", + # ), ) date_deadline = fields.Datetime( compute="_compute_date_deadline", store=True, readonly=False, required=True ) + # release_channel_id = fields.Many2one("stock.release.channel", required=True) @api.constrains("date_deadline") def _constrains_date_deadline(self): @@ -36,8 +54,8 @@ def _constrains_date_deadline(self): def _selection_option(self): options = [ - ("free", "Free"), - ("asap", "As soon as possible"), + ("manual", "Manual"), + ("automatic", "Automatic / As soon as possible"), ] if self.env.context.get("from_sale_order_id"): options.append(("contextual", "From contextual sale order")) @@ -49,7 +67,7 @@ def _compute_date_deadline(self): order = self.env["sale.order"].browse(from_sale_order_id).exists() for rec in self: rec.date_deadline = False - if rec.option == "asap": + if rec.option == "automatic": rec.date_deadline = fields.Datetime.now() elif rec.option == "contextual" and order: rec.date_deadline = order.commitment_date or order.expected_date diff --git a/sale_stock_available_to_promise_release_block/wizards/unblock_release.xml b/sale_stock_available_to_promise_release_block/wizards/unblock_release.xml index e2641330d2..27a7582776 100644 --- a/sale_stock_available_to_promise_release_block/wizards/unblock_release.xml +++ b/sale_stock_available_to_promise_release_block/wizards/unblock_release.xml @@ -13,9 +13,17 @@ +