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..a002443506e0 --- /dev/null +++ b/sale_stock_available_to_promise_release/__manifest__.py @@ -0,0 +1,20 @@ +# 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': '12.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..1067b6665155 --- /dev/null +++ b/sale_stock_available_to_promise_release/models/sale_order_line.py @@ -0,0 +1,14 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, models + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + @api.multi + def _prepare_procurement_values(self, group_id=False): + values = super()._prepare_procurement_values(group_id) + values['date_priority'] = self.order_id.confirmation_date + 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..2ff19e4a47f5 --- /dev/null +++ b/stock_available_to_promise_release/__manifest__.py @@ -0,0 +1,23 @@ +# 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': '12.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..95093f473bda --- /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..54bcae145280 --- /dev/null +++ b/stock_available_to_promise_release/models/stock_move.py @@ -0,0 +1,160 @@ +# 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.addons import decimal_precision as dp +from odoo.osv import expression +from odoo.tools import float_compare + + +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. + 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., + ) + + 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. + ) + ) + ) + return promised_qty + + @api.multi + 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 + + @api.multi + 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" + ) + 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 delivery unless we can deliver all at + # once + continue + move.with_context(release_available_to_promise=True)._split( + remaining + ) + + values = move._prepare_procurement_values() + + self.env["procurement.group"].run_defer( + move.product_id, + move.product_id.uom_id._compute_quantity( + quantity, move.product_uom, rounding_method="HALF-UP" + ), + move.product_uom, + move.location_id, + move.origin, + values, + ) + + pull_move = move + while pull_move: + pull_move._action_assign() + pull_move = pull_move.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..4623fa8e647f --- /dev/null +++ b/stock_available_to_promise_release/models/stock_picking.py @@ -0,0 +1,24 @@ +# 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 + ) + + @api.multi + 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..683dce6e1a69 --- /dev/null +++ b/stock_available_to_promise_release/models/stock_rule.py @@ -0,0 +1,83 @@ +# 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 api, fields, models + +_logger = logging.getLogger(__name__) + + +class StockRule(models.Model): + _inherit = "stock.rule" + + def _run_pull( + self, + product_id, + product_qty, + product_uom, + location_id, + name, + origin, + values, + ): + if ( + not self.env.context.get("_rule_no_available_defer") + and self.route_id.available_to_promise_defer_pull + # We still want to create the first part of the chain + and not self.picking_type_id.code == "outgoing" + ): + moves = 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}) + return True + + super()._run_pull( + product_id, + product_qty, + product_uom, + location_id, + name, + origin, + values, + ) + moves = values.get("move_dest_ids") + if moves: + moves.filtered(lambda r: r.need_release).write( + {"need_release": False} + ) + return True + + +class ProcurementGroup(models.Model): + _inherit = "procurement.group" + + @api.model + def run_defer( + self, product_id, product_qty, product_uom, location_id, origin, values + ): + values.setdefault( + "company_id", + self.env["res.company"]._company_default_get("procurement.group"), + ) + values.setdefault("priority", "1") + values.setdefault("date_planned", fields.Datetime.now()) + rule = self._get_rule(product_id, location_id, values) + if not rule or rule.action not in ("pull", "pull_push"): + return + + rule.with_context(_rule_no_available_defer=True)._run_pull( + product_id, + product_qty, + product_uom, + location_id, + rule.name, + origin, + values, + ) + 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..761d26290844 --- /dev/null +++ b/stock_available_to_promise_release/readme/USAGE.rst @@ -0,0 +1,8 @@ +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..a2d3dfbb2725 --- /dev/null +++ b/stock_available_to_promise_release/tests/test_reservation.py @@ -0,0 +1,470 @@ +# 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( + product, qty, uom, self.loc_customer, "TEST", "TEST", 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.) + + self._update_qty_in_location(self.loc_bin1, self.product1, 20.) + + 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.) + 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.) + pickings = self._create_picking_chain( + self.wh, [(self.product1, 10.)], 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.) + # 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.) + + 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.}]) + + 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., + "reserved_availability": 7., + "procure_method": "make_to_order", + }, + { + "state": "waiting", + "product_qty": 13., + "reserved_availability": 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., + "reserved_availability": 13., + "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.) + + 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., "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.) + 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., + "product_uom_qty": 2., + "ordered_available_to_promise": 12., + } + ], + ) + + 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., + "reserved_availability": 1., + "product_uom_qty": 1., + } + ], + ) + + 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..bc94400af592 --- /dev/null +++ b/stock_available_to_promise_release/views/stock_move_views.xml @@ -0,0 +1,64 @@ + + + + + Stock Moves Release + stock.move + primary + + + + + + + + + + + + stock.move.release.form + stock.move + primary + + + + + + + + + + + + Stock Moves To Release + stock.move + ir.actions.act_window + form + 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..84336a019582 --- /dev/null +++ b/stock_available_to_promise_release/wizards/stock_move_release.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 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..0ea9ad5310af --- /dev/null +++ b/stock_available_to_promise_release/wizards/stock_move_release_views.xml @@ -0,0 +1,34 @@ + + + + Stock Move Release + stock.move.release + +
+

+ The selected stock moves will be released. +

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