diff --git a/sale_stock_available_to_promise_release/__init__.py b/sale_stock_available_to_promise_release/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /dev/null +++ b/sale_stock_available_to_promise_release/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_stock_available_to_promise_release/__manifest__.py b/sale_stock_available_to_promise_release/__manifest__.py new file mode 100644 index 000000000000..7251a2d5f010 --- /dev/null +++ b/sale_stock_available_to_promise_release/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Stock Available to Promise Release - Sale Integration", + "version": "13.0.1.0.0", + "summary": "Integration between Sales and Available to Promise Release", + "author": "Camptocamp,Odoo Community Association (OCA)", + "category": "Stock Management", + "depends": ["sale_stock", "stock_available_to_promise_release"], + "data": [], + "installable": True, + "license": "AGPL-3", + "application": False, + "development_status": "Alpha", +} diff --git a/sale_stock_available_to_promise_release/models/__init__.py b/sale_stock_available_to_promise_release/models/__init__.py new file mode 100644 index 000000000000..8eb9d1d40467 --- /dev/null +++ b/sale_stock_available_to_promise_release/models/__init__.py @@ -0,0 +1 @@ +from . import sale_order_line diff --git a/sale_stock_available_to_promise_release/models/sale_order_line.py b/sale_stock_available_to_promise_release/models/sale_order_line.py new file mode 100644 index 000000000000..e4b2aeac4971 --- /dev/null +++ b/sale_stock_available_to_promise_release/models/sale_order_line.py @@ -0,0 +1,13 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import models + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + def _prepare_procurement_values(self, group_id=False): + values = super()._prepare_procurement_values(group_id) + values["date_priority"] = self.order_id.date_order + return values diff --git a/sale_stock_available_to_promise_release/readme/CONTRIBUTORS.rst b/sale_stock_available_to_promise_release/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000000..48286263cd35 --- /dev/null +++ b/sale_stock_available_to_promise_release/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Guewen Baconnier diff --git a/sale_stock_available_to_promise_release/readme/DESCRIPTION.rst b/sale_stock_available_to_promise_release/readme/DESCRIPTION.rst new file mode 100644 index 000000000000..8d74dcc1d421 --- /dev/null +++ b/sale_stock_available_to_promise_release/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +Integrate the Release of Operation based on Available to Promise with Sales. The Priority Date of Stock +Moves will be equal to the confirmation date of their sales order. diff --git a/setup/sale_stock_available_to_promise_release/odoo/addons/sale_stock_available_to_promise_release b/setup/sale_stock_available_to_promise_release/odoo/addons/sale_stock_available_to_promise_release new file mode 120000 index 000000000000..84bd7c10a70d --- /dev/null +++ b/setup/sale_stock_available_to_promise_release/odoo/addons/sale_stock_available_to_promise_release @@ -0,0 +1 @@ +../../../../sale_stock_available_to_promise_release \ No newline at end of file diff --git a/setup/sale_stock_available_to_promise_release/setup.py b/setup/sale_stock_available_to_promise_release/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/sale_stock_available_to_promise_release/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/stock_available_to_promise_release/odoo/addons/stock_available_to_promise_release b/setup/stock_available_to_promise_release/odoo/addons/stock_available_to_promise_release new file mode 120000 index 000000000000..6c1e5ad79b37 --- /dev/null +++ b/setup/stock_available_to_promise_release/odoo/addons/stock_available_to_promise_release @@ -0,0 +1 @@ +../../../../stock_available_to_promise_release \ No newline at end of file diff --git a/setup/stock_available_to_promise_release/setup.py b/setup/stock_available_to_promise_release/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/stock_available_to_promise_release/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_available_to_promise_release/__init__.py b/stock_available_to_promise_release/__init__.py new file mode 100644 index 000000000000..aee8895e7a31 --- /dev/null +++ b/stock_available_to_promise_release/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/stock_available_to_promise_release/__manifest__.py b/stock_available_to_promise_release/__manifest__.py new file mode 100644 index 000000000000..46da81eced4e --- /dev/null +++ b/stock_available_to_promise_release/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Stock Available to Promise Release", + "version": "13.0.1.0.0", + "summary": "Release Operations based on available to promise", + "author": "Camptocamp,Odoo Community Association (OCA)", + "category": "Stock Management", + "depends": ["stock"], + "data": [ + "views/stock_move_views.xml", + "views/stock_picking_views.xml", + "views/stock_location_route_views.xml", + "wizards/stock_move_release_views.xml", + ], + "installable": True, + "license": "AGPL-3", + "application": False, + "development_status": "Alpha", +} diff --git a/stock_available_to_promise_release/models/__init__.py b/stock_available_to_promise_release/models/__init__.py new file mode 100644 index 000000000000..3fa0e1faf81c --- /dev/null +++ b/stock_available_to_promise_release/models/__init__.py @@ -0,0 +1,4 @@ +from . import stock_move +from . import stock_location_route +from . import stock_picking +from . import stock_rule diff --git a/stock_available_to_promise_release/models/stock_location_route.py b/stock_available_to_promise_release/models/stock_location_route.py new file mode 100644 index 000000000000..4dbc53ea646f --- /dev/null +++ b/stock_available_to_promise_release/models/stock_location_route.py @@ -0,0 +1,16 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class Route(models.Model): + _inherit = "stock.location.route" + + available_to_promise_defer_pull = fields.Boolean( + string="Release based on Available to Promise", + default=False, + help="Do not create chained moved automatically for delivery. " + "Transfers must be released manually when they have enough available" + " to promise.", + ) diff --git a/stock_available_to_promise_release/models/stock_move.py b/stock_available_to_promise_release/models/stock_move.py new file mode 100644 index 000000000000..e972a4846dc6 --- /dev/null +++ b/stock_available_to_promise_release/models/stock_move.py @@ -0,0 +1,147 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models +from odoo.osv import expression +from odoo.tools import float_compare + +from odoo.addons import decimal_precision as dp + + +class StockMove(models.Model): + _inherit = "stock.move" + + date_priority = fields.Datetime( + string="Priority Date", + index=True, + default=fields.Datetime.now, + help="Date/time used to sort moves to deliver first. " + "Used to calculate the ordered available to promise.", + ) + ordered_available_to_promise = fields.Float( + "Ordered Available to Promise", + compute="_compute_ordered_available_to_promise", + digits=dp.get_precision("Product Unit of Measure"), + help="Available to Promise quantity minus quantities promised " + " to older promised operations.", + ) + need_release = fields.Boolean() + + @api.depends() + def _compute_ordered_available_to_promise(self): + for move in self: + move.ordered_available_to_promise = move._ordered_available_to_promise() + + def _should_compute_ordered_available_to_promise(self): + return ( + self.picking_code == "outgoing" + and self.need_release + and not self.product_id.type == "consu" + and not self.location_id.should_bypass_reservation() + ) + + def _action_cancel(self): + super()._action_cancel() + self.write({"need_release": False}) + return True + + def _ordered_available_to_promise(self): + if not self._should_compute_ordered_available_to_promise(): + return 0.0 + available = self.product_id.with_context( + location=self.warehouse_id.lot_stock_id.id + ).virtual_available + return max( + min(available - self._previous_promised_qty(), self.product_qty), 0.0 + ) + + def _previous_promised_quantity_domain(self): + domain = [ + ("need_release", "=", True), + ("product_id", "=", self.product_id.id), + ("date_priority", "<=", self.date_priority), + ("warehouse_id", "=", self.warehouse_id.id), + ] + return domain + + def _previous_promised_qty(self): + previous_moves = self.search( + expression.AND( + [self._previous_promised_quantity_domain(), [("id", "!=", self.id)]] + ) + ) + promised_qty = sum( + previous_moves.mapped( + lambda move: max(move.product_qty - move.reserved_availability, 0.0) + ) + ) + return promised_qty + + def release_available_to_promise(self): + self._run_stock_rule() + + def _prepare_move_split_vals(self, qty): + vals = super()._prepare_move_split_vals(qty) + # The method set procure_method as 'make_to_stock' by default on split, + # but we want to keep 'make_to_order' for chained moves when we split + # a partially available move in _run_stock_rule(). + if self.env.context.get("release_available_to_promise"): + vals.update({"procure_method": self.procure_method, "need_release": True}) + return vals + + def _run_stock_rule(self): + """Launch procurement group run method with remaining quantity + + As we only generate chained moves for the quantity available minus the + quantity promised to older moves, to delay the reservation at the + latest, we have to periodically retry to assign the remaining + quantities. + """ + precision = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) + procurement_requests = [] + pulled_moves = self.env["stock.move"] + for move in self: + if not move.need_release: + continue + if move.state not in ("confirmed", "waiting"): + continue + # do not use the computed field, because it will keep + # a value in cache that we cannot invalidate declaratively + available_quantity = move._ordered_available_to_promise() + if float_compare(available_quantity, 0, precision_digits=precision) <= 0: + continue + + quantity = min(move.product_qty, available_quantity) + remaining = move.product_qty - quantity + + if float_compare(remaining, 0, precision_digits=precision) > 0: + if move.picking_id.move_type == "one": + # we don't want to deliver unless we can deliver all at + # once + continue + move.with_context(release_available_to_promise=True)._split(remaining) + + values = move._prepare_procurement_values() + procurement_requests.append( + self.env["procurement.group"].Procurement( + move.product_id, + move.product_uom_qty, + move.product_uom, + move.location_id, + move.rule_id and move.rule_id.name or "/", + move.origin, + move.company_id, + values, + ) + ) + pulled_moves |= move + + self.env["procurement.group"].run_defer(procurement_requests) + + while pulled_moves: + pulled_moves._action_assign() + pulled_moves = pulled_moves.mapped("move_orig_ids") + + return True diff --git a/stock_available_to_promise_release/models/stock_picking.py b/stock_available_to_promise_release/models/stock_picking.py new file mode 100644 index 000000000000..8471c204de67 --- /dev/null +++ b/stock_available_to_promise_release/models/stock_picking.py @@ -0,0 +1,21 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + # Add store on the field, as it is quite used in the searches, + # and this is an easy-win to reduce the number of SQL queries. + picking_type_code = fields.Selection(store=True) + need_release = fields.Boolean(compute="_compute_need_release") + + @api.depends("move_lines.need_release") + def _compute_need_release(self): + for picking in self: + picking.need_release = any(move.need_release for move in picking.move_lines) + + def release_available_to_promise(self): + self.mapped("move_lines").release_available_to_promise() diff --git a/stock_available_to_promise_release/models/stock_rule.py b/stock_available_to_promise_release/models/stock_rule.py new file mode 100644 index 000000000000..51a9d6d5c3ad --- /dev/null +++ b/stock_available_to_promise_release/models/stock_rule.py @@ -0,0 +1,66 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class StockRule(models.Model): + _inherit = "stock.rule" + + def _run_pull(self, procurements): + actions_to_run = [] + + for procurement, rule in procurements: + if ( + not self.env.context.get("_rule_no_available_defer") + and rule.route_id.available_to_promise_defer_pull + # We still want to create the first part of the chain + and not rule.picking_type_id.code == "outgoing" + ): + moves = procurement.values.get("move_dest_ids") + # Track the moves that needs to have their pull rule + # done. Before the 'pull' is done, we don't know the + # which route is chosen. We update the destination + # move (ie. the outgoing) when the current route + # defers the pull rules and return so we don't create + # the next move of the chain (pick or pack). + if moves: + moves.write({"need_release": True}) + else: + actions_to_run.append((procurement, rule)) + + super()._run_pull(actions_to_run) + # use first of list of ids and browse it for performance + move_ids = [ + move.id + for move in procurement.values.get("move_dest_ids", []) + for procurement, _rule in actions_to_run + ] + if move_ids: + moves = self.env["stock.move"].browse(move_ids) + moves.filtered(lambda r: r.need_release).write({"need_release": False}) + return True + + +class ProcurementGroup(models.Model): + _inherit = "procurement.group" + + def run_defer(self, procurements): + actions_to_run = [] + for procurement in procurements: + values = procurement.values + values.setdefault("company_id", self.env.company) + values.setdefault("priority", "1") + values.setdefault("date_planned", fields.Datetime.now()) + rule = self._get_rule( + procurement.product_id, procurement.location_id, procurement.values + ) + if rule.action in ("pull", "pull_push"): + actions_to_run.append((procurement, rule)) + + if actions_to_run: + rule.with_context(_rule_no_available_defer=True)._run_pull(actions_to_run) + return True diff --git a/stock_available_to_promise_release/readme/CONFIGURE.rst b/stock_available_to_promise_release/readme/CONFIGURE.rst new file mode 100644 index 000000000000..ac262039b394 --- /dev/null +++ b/stock_available_to_promise_release/readme/CONFIGURE.rst @@ -0,0 +1,2 @@ +In Inventory > Configuration > Warehouses, activate the option "Release based on Available to Promise" +when you want to use the feature. diff --git a/stock_available_to_promise_release/readme/CONTRIBUTORS.rst b/stock_available_to_promise_release/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000000..48286263cd35 --- /dev/null +++ b/stock_available_to_promise_release/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Guewen Baconnier diff --git a/stock_available_to_promise_release/readme/DESCRIPTION.rst b/stock_available_to_promise_release/readme/DESCRIPTION.rst new file mode 100644 index 000000000000..f72fbab6a7d1 --- /dev/null +++ b/stock_available_to_promise_release/readme/DESCRIPTION.rst @@ -0,0 +1,28 @@ +Currently the reservation is performed by adding reserved quantities on quants, +which is fine as long as the reservation is made right after the order +confirmation. This way, the first arrived, first served principle is always +applied. But if you release warehouse operations in a chosen order (through +deliver round for example), then you need to be sure the reservations are made +in respect to the first arrived first served principle and not driven by the +order you choose to release your operations. + +Allow each delivery move to mark a quantity as virtually reserved. Simple rule +would be first ordered, first served. More complex rules could be implemented. + +When the reservation of a picking move occurs, the quantity that is reserved is +then based on the quantity that was promised to the customer (available to promise): + +* The moves can be reserved in any order, the right quantity is always reserved +* The removal strategy is computed only when the reservation occurs. If you + reserve order 2 before order 1 (because you have/want to deliver order 2) you + can apply correctly fifo/fefo. + + * For instance order 1 must be delivered in 1 month, order 2 must be delivered now. + * Virtually lock quantities to be able to serve order 1 + * Reserve remaining quantity for order 2 and apply fefo + +* Allow to limit the promised quantity in time. If a customer orders now for a + planned delivery in 2 months, then allow to not lock this quantity as + virtually reserved +* Allow to perform reservations jointly with your delivery rounds planning. + Reserve only the quants you planned to deliver. diff --git a/stock_available_to_promise_release/readme/USAGE.rst b/stock_available_to_promise_release/readme/USAGE.rst new file mode 100644 index 000000000000..7383fae4dd9e --- /dev/null +++ b/stock_available_to_promise_release/readme/USAGE.rst @@ -0,0 +1,7 @@ +When an outgoing transfer would generate chained moves, it will not. The chained +moves need to be released manually. To do so, open "Inventory > Operations > +Stock Moves to Release", select the moves to release and use "action > Release +Stock Move". A move can be released only if the available to promise quantity is +greater than zero. This quantity is computed as the product's virtual quantity +minus the previous moves in the list (previous being defined by the field +"Priority Date"). diff --git a/stock_available_to_promise_release/tests/__init__.py b/stock_available_to_promise_release/tests/__init__.py new file mode 100644 index 000000000000..9c0fd33d4bf2 --- /dev/null +++ b/stock_available_to_promise_release/tests/__init__.py @@ -0,0 +1 @@ +from . import test_reservation diff --git a/stock_available_to_promise_release/tests/test_reservation.py b/stock_available_to_promise_release/tests/test_reservation.py new file mode 100644 index 000000000000..a64f76d4a1dd --- /dev/null +++ b/stock_available_to_promise_release/tests/test_reservation.py @@ -0,0 +1,439 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from datetime import datetime + +from odoo import fields +from odoo.tests import common + + +class TestAvailableToPromiseRelease(common.SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.wh = cls.env["stock.warehouse"].create( + { + "name": "Test Warehouse", + "reception_steps": "one_step", + "delivery_steps": "pick_ship", + "code": "WHTEST", + } + ) + cls.loc_stock = cls.wh.lot_stock_id + cls.loc_customer = cls.env.ref("stock.stock_location_customers") + cls.product1 = cls.env["product.product"].create( + {"name": "Product 1", "type": "product"} + ) + cls.product2 = cls.env["product.product"].create( + {"name": "Product 2", "type": "product"} + ) + cls.partner_delta = cls.env.ref("base.res_partner_4") + cls.loc_bin1 = cls.env["stock.location"].create( + {"name": "Bin1", "location_id": cls.loc_stock.id} + ) + + def _create_picking_chain(self, wh, products=None, date=None, move_type="direct"): + """Create picking chain + + It runs the procurement group to create the moves required for + a product. According to the WH, it creates the pick/pack/ship + moves. + + Products must be a list of tuples (product, quantity) or + (product, quantity, uom). + One stock move will be created for each tuple. + """ + + if products is None: + products = [] + + group = self.env["procurement.group"].create( + { + "name": "TEST", + "move_type": move_type, + "partner_id": self.partner_delta.id, + } + ) + values = { + "company_id": wh.company_id, + "group_id": group, + "date_planned": date or fields.Datetime.now(), + "warehouse_id": wh, + } + + for row in products: + if len(row) == 2: + product, qty = row + uom = product.uom_id + elif len(row) == 3: + product, qty, uom = row + else: + raise ValueError( + "Expect (product, quantity, uom) or (product, quantity)" + ) + + self.env["procurement.group"].run( + [ + self.env["procurement.group"].Procurement( + product, + qty, + uom, + self.loc_customer, + "TEST", + "TEST", + wh.company_id, + values, + ) + ] + ) + pickings = self._pickings_in_group(group) + pickings.mapped("move_lines").write( + {"date_priority": date or fields.Datetime.now()} + ) + return pickings + + def _pickings_in_group(self, group): + return self.env["stock.picking"].search([("group_id", "=", group.id)]) + + def _update_qty_in_location(self, location, product, quantity): + self.env["stock.quant"]._update_available_quantity(product, location, quantity) + self.env["product.product"].invalidate_cache( + fnames=[ + "qty_available", + "virtual_available", + "incoming_qty", + "outgoing_qty", + ] + ) + + def _prev_picking(self, picking): + return picking.move_lines.move_orig_ids.picking_id + + def _out_picking(self, pickings): + return pickings.filtered(lambda r: r.picking_type_code == "outgoing") + + def _deliver(self, picking): + picking.action_assign() + for line in picking.mapped("move_lines.move_line_ids"): + line.qty_done = line.product_qty + picking.action_done() + + def test_ordered_available_to_promise_value(self): + self.wh.delivery_route_id.write({"available_to_promise_defer_pull": True}) + picking = self._out_picking( + self._create_picking_chain( + self.wh, [(self.product1, 5)], date=datetime(2019, 9, 2, 16, 0) + ) + ) + picking2 = self._out_picking( + self._create_picking_chain( + self.wh, [(self.product1, 3)], date=datetime(2019, 9, 2, 16, 1) + ) + ) + # we'll assign this one in the test, should deduct pick 1 and 2 + picking3 = self._out_picking( + self._create_picking_chain( + self.wh, [(self.product1, 20)], date=datetime(2019, 9, 3, 16, 0) + ) + ) + # this one should be ignored when we'll assign pick 3 as it has + # a later date + picking4 = self._out_picking( + self._create_picking_chain( + self.wh, [(self.product1, 20)], date=datetime(2019, 9, 4, 16, 1) + ) + ) + + for pick in (picking, picking2, picking3, picking4): + self.assertEqual(pick.state, "waiting") + self.assertEqual(pick.move_lines.reserved_availability, 0.0) + + self._update_qty_in_location(self.loc_bin1, self.product1, 20.0) + + self.assertEqual(picking.move_lines._ordered_available_to_promise(), 5) + self.assertEqual(picking2.move_lines._ordered_available_to_promise(), 3) + self.assertEqual(picking3.move_lines._ordered_available_to_promise(), 12) + self.assertEqual(picking4.move_lines._ordered_available_to_promise(), 0) + + def test_normal_chain(self): + # usual scenario, without using the option to defer the pull + pickings = self._create_picking_chain(self.wh, [(self.product1, 5)]) + self.assertEqual(len(pickings), 2, "expect stock->out + out->customer") + self.assertRecordValues( + pickings.sorted("id"), + [ + { + "location_id": self.wh.lot_stock_id.id, + "location_dest_id": self.wh.wh_output_stock_loc_id.id, + }, + { + "location_id": self.wh.wh_output_stock_loc_id.id, + "location_dest_id": self.loc_customer.id, + }, + ], + ) + + def test_defer_creation(self): + self.wh.delivery_route_id.write({"available_to_promise_defer_pull": True}) + + self._update_qty_in_location(self.loc_bin1, self.product1, 20.0) + pickings = self._create_picking_chain(self.wh, [(self.product1, 5)]) + + self.assertEqual(len(pickings), 1, "expect only the last out->customer") + cust_picking = pickings + self.assertRecordValues( + cust_picking, + [ + { + "state": "waiting", + "location_id": self.wh.wh_output_stock_loc_id.id, + "location_dest_id": self.loc_customer.id, + } + ], + ) + + cust_picking.move_lines.release_available_to_promise() + out_picking = self._pickings_in_group(pickings.group_id) - cust_picking + + self.assertRecordValues( + out_picking, + [ + { + "state": "assigned", + "location_id": self.wh.lot_stock_id.id, + "location_dest_id": self.wh.wh_output_stock_loc_id.id, + } + ], + ) + + def test_defer_creation_move_type_one(self): + """Deliver all products at once""" + self.wh.delivery_route_id.write({"available_to_promise_defer_pull": True}) + + self._update_qty_in_location(self.loc_bin1, self.product1, 5.0) + pickings = self._create_picking_chain( + self.wh, [(self.product1, 10.0)], move_type="one" + ) + self.assertEqual(len(pickings), 1, "expect only the last out->customer") + cust_picking = pickings + self.assertRecordValues( + cust_picking, + [ + { + "state": "waiting", + "location_id": self.wh.wh_output_stock_loc_id.id, + "location_dest_id": self.loc_customer.id, + } + ], + ) + + cust_picking.move_lines.release_available_to_promise() + # no chain picking should have been created because we would have a + # partial and the move delivery type is "one" + out_picking = self._pickings_in_group(pickings.group_id) - cust_picking + self.assertFalse(out_picking) + + self._update_qty_in_location(self.loc_bin1, self.product1, 10.0) + # now, we have enough, the picking is created + cust_picking.move_lines.release_available_to_promise() + out_picking = self._pickings_in_group(pickings.group_id) - cust_picking + self.assertRecordValues( + out_picking, + [ + { + "state": "assigned", + "location_id": self.wh.lot_stock_id.id, + "location_dest_id": self.wh.wh_output_stock_loc_id.id, + } + ], + ) + + def test_defer_creation_backorder(self): + self.wh.delivery_route_id.write({"available_to_promise_defer_pull": True}) + + self._update_qty_in_location(self.loc_bin1, self.product1, 7.0) + + pickings = self._create_picking_chain(self.wh, [(self.product1, 20)]) + self.assertEqual(len(pickings), 1, "expect only the last out->customer") + cust_picking = pickings + self.assertRecordValues( + cust_picking, + [ + { + "state": "waiting", + "location_id": self.wh.wh_output_stock_loc_id.id, + "location_dest_id": self.loc_customer.id, + } + ], + ) + + cust_picking.release_available_to_promise() + + out_picking = self._pickings_in_group(pickings.group_id) - cust_picking + + self.assertRecordValues( + out_picking, + [ + { + "state": "assigned", + "location_id": self.wh.lot_stock_id.id, + "location_dest_id": self.wh.wh_output_stock_loc_id.id, + } + ], + ) + + self.assertRecordValues(out_picking.move_lines, [{"product_qty": 7.0}]) + + self._deliver(out_picking) + self.assertRecordValues(out_picking, [{"state": "done"}]) + + self.assertRecordValues(cust_picking, [{"state": "assigned"}]) + self.assertRecordValues( + cust_picking.move_lines, + [ + { + "state": "assigned", + "product_qty": 7.0, + "reserved_availability": 7.0, + "procure_method": "make_to_order", + }, + { + "state": "waiting", + "product_qty": 13.0, + "reserved_availability": 0.0, + "procure_method": "make_to_order", + }, + ], + ) + + self._deliver(cust_picking) + self.assertRecordValues(cust_picking, [{"state": "done"}]) + + cust_backorder = ( + self._pickings_in_group(cust_picking.group_id) - cust_picking - out_picking + ) + self.assertEqual(len(cust_backorder), 1) + + # nothing happen, no stock + self.assertEqual(len(self._pickings_in_group(cust_picking.group_id)), 3) + cust_backorder.release_available_to_promise() + self.assertEqual(len(self._pickings_in_group(cust_picking.group_id)), 3) + + # We add stock, so now the release must create the next + # chained move + self._update_qty_in_location(self.loc_bin1, self.product1, 30) + cust_backorder.release_available_to_promise() + out_backorder = ( + self._pickings_in_group(cust_picking.group_id) + - cust_backorder + - cust_picking + - out_picking + ) + self.assertRecordValues( + out_backorder.move_lines, + [ + { + "state": "assigned", + "product_qty": 13.0, + "reserved_availability": 13.0, + "procure_method": "make_to_stock", + "location_id": self.wh.lot_stock_id.id, + "location_dest_id": self.wh.wh_output_stock_loc_id.id, + } + ], + ) + + def test_defer_multi_move(self): + self.wh.delivery_route_id.write({"available_to_promise_defer_pull": True}) + + self._update_qty_in_location(self.loc_bin1, self.product2, 10.0) + + pickings = self._create_picking_chain( + self.wh, [(self.product1, 20), (self.product2, 10)] + ) + self.assertEqual(len(pickings), 1, "expect only the last out->customer") + cust_picking = pickings + self.assertRecordValues( + cust_picking, + [ + { + "state": "waiting", + "location_id": self.wh.wh_output_stock_loc_id.id, + "location_dest_id": self.loc_customer.id, + } + ], + ) + + cust_picking.release_available_to_promise() + + out_picking = self._pickings_in_group(pickings.group_id) - cust_picking + + self.assertRecordValues( + out_picking, + [ + { + "state": "assigned", + "location_id": self.wh.lot_stock_id.id, + "location_dest_id": self.wh.wh_output_stock_loc_id.id, + } + ], + ) + + self.assertRecordValues( + out_picking.move_lines, + [{"product_qty": 10.0, "product_id": self.product2.id}], + ) + + def test_defer_creation_uom(self): + self.wh.delivery_route_id.write({"available_to_promise_defer_pull": True}) + + self._update_qty_in_location(self.loc_bin1, self.product1, 12.0) + uom_dozen = self.env.ref("uom.product_uom_dozen") + pickings = self._create_picking_chain( + self.wh, + # means 24 products + [(self.product1, 2, uom_dozen)], + ) + self.assertEqual(len(pickings), 1, "expect only the last out->customer") + cust_picking = pickings + self.assertRecordValues( + cust_picking, + [ + { + "state": "waiting", + "location_id": self.wh.wh_output_stock_loc_id.id, + "location_dest_id": self.loc_customer.id, + } + ], + ) + self.assertRecordValues( + cust_picking.move_lines, + [ + { + "state": "waiting", + "product_uom": uom_dozen.id, + "product_qty": 24.0, + "product_uom_qty": 2.0, + "ordered_available_to_promise": 12.0, + } + ], + ) + + cust_picking.move_lines.release_available_to_promise() + out_picking = self._pickings_in_group(pickings.group_id) - cust_picking + + self.assertRecordValues( + out_picking.move_lines, + [ + { + "state": "assigned", + "product_qty": 12.0, + "reserved_availability": 1.0, + "product_uom_qty": 1.0, + } + ], + ) + + def test_mto_picking(self): + self.wh.delivery_route_id.write({"available_to_promise_defer_pull": True}) + # TODO a MTO picking should work normally diff --git a/stock_available_to_promise_release/views/stock_location_route_views.xml b/stock_available_to_promise_release/views/stock_location_route_views.xml new file mode 100644 index 000000000000..cb1fb563178b --- /dev/null +++ b/stock_available_to_promise_release/views/stock_location_route_views.xml @@ -0,0 +1,15 @@ + + + + + stock.location.route.form + stock.location.route + + + + + + + + + diff --git a/stock_available_to_promise_release/views/stock_move_views.xml b/stock_available_to_promise_release/views/stock_move_views.xml new file mode 100644 index 000000000000..b12fc8c0324f --- /dev/null +++ b/stock_available_to_promise_release/views/stock_move_views.xml @@ -0,0 +1,63 @@ + + + + + Stock Moves Release + stock.move + primary + + + + + + + + + + + + stock.move.release.form + stock.move + primary + + + + + + + + + + + + Stock Moves To Release + stock.move + ir.actions.act_window + tree,kanban,form + + + {'search_default_groupby_picking_id': 1} + [('need_release', '=' , True)] + +

+ Create a new stock movement +

+ This menu gives you the full traceability of inventory + operations on a specific product. You can filter on the product + to see all the past or future movements for the product. +

+
+
+ + + +
diff --git a/stock_available_to_promise_release/views/stock_picking_views.xml b/stock_available_to_promise_release/views/stock_picking_views.xml new file mode 100644 index 000000000000..9c21d37acfbe --- /dev/null +++ b/stock_available_to_promise_release/views/stock_picking_views.xml @@ -0,0 +1,21 @@ + + + + + stock.picking.release.form + stock.picking + + + + + + + diff --git a/stock_available_to_promise_release/wizards/__init__.py b/stock_available_to_promise_release/wizards/__init__.py new file mode 100644 index 000000000000..571bfd6aab00 --- /dev/null +++ b/stock_available_to_promise_release/wizards/__init__.py @@ -0,0 +1 @@ +from . import stock_move_release diff --git a/stock_available_to_promise_release/wizards/stock_move_release.py b/stock_available_to_promise_release/wizards/stock_move_release.py new file mode 100644 index 000000000000..81c6295a612f --- /dev/null +++ b/stock_available_to_promise_release/wizards/stock_move_release.py @@ -0,0 +1,18 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import models + + +class StockMoveRelease(models.TransientModel): + _name = "stock.move.release" + _description = "Stock Move Release" + + def release(self): + moves = ( + self.env["stock.move"] + .browse(self.env.context.get("active_ids", [])) + .exists() + ) + moves.release_available_to_promise() + return {"type": "ir.actions.act_window_close"} diff --git a/stock_available_to_promise_release/wizards/stock_move_release_views.xml b/stock_available_to_promise_release/wizards/stock_move_release_views.xml new file mode 100644 index 000000000000..d532b6da62fe --- /dev/null +++ b/stock_available_to_promise_release/wizards/stock_move_release_views.xml @@ -0,0 +1,33 @@ + + + + Stock Move Release + stock.move.release + +
+

+ The selected stock moves will be released. +

+
+
+
+
+
+ + + Release Stock Move + ir.actions.act_window + stock.move.release + form + new + + + + +