-
-
Notifications
You must be signed in to change notification settings - Fork 729
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[WIP][WMS][12.0] Available to Promise Release #709
Changes from 25 commits
fccde3b
3b9a06f
bcdc3cb
0ce3fc1
78114e0
5266f09
a900e1f
bd22ef3
cd49bf4
9850979
f25aad8
7f5ab0a
8fb47cc
6df26bd
95f390a
dc81494
f02ea74
d501d75
1581146
836668f
54f5641
d9b04e2
c13ad92
651d86e
6a9cd38
84cd0ba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from . import models |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from . import sale_order_line |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
* Guewen Baconnier <guewen.baconnier@camptocamp.com> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
../../../../sale_stock_available_to_promise_release |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import setuptools | ||
|
||
setuptools.setup( | ||
setup_requires=['setuptools-odoo'], | ||
odoo_addon=True, | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
../../../../stock_available_to_promise_release |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import setuptools | ||
|
||
setuptools.setup( | ||
setup_requires=['setuptools-odoo'], | ||
odoo_addon=True, | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
from . import models | ||
from . import wizards |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
from . import stock_move | ||
from . import stock_location_route | ||
from . import stock_picking | ||
from . import stock_rule |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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.", | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
# 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 not self.product_id.type == "consu" | ||
and not self.location_id.should_bypass_reservation() | ||
) | ||
|
||
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), | ||
] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ideally we should exclude all the moves planned in the future that you can resupply on time. For example, there is no reason a delivery planned 3 months is blocking quantity while you can resupply in 7 days. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We go for the horizon solution here |
||
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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. use the field "move._ordered_available_to_promise" instead There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is the explanation in the comment just above why the field is not used ;) |
||
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
In Inventory > Configuration > Warehouses, activate the option "Release based on Available to Promise" | ||
when you want to use the feature. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
* Guewen Baconnier <guewen.baconnier@camptocamp.com> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this method not part of _compute_ordered_available_to_promise ? Retrieving the value can be done by reading the field.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As we can't have proper depends, if values change in the transaction, the field will not be recomputed.
We could add invalidations everywhere, not sure that's useful. The field is good for the view, but the code better has to recompute the value on every access. I should add a docstring about this.