From 56efb55057b9b06fe335df20d1696b49468334fb Mon Sep 17 00:00:00 2001 From: Arnold Moyaux Date: Thu, 28 Nov 2019 15:42:23 +0000 Subject: [PATCH] [IMP] stock, mrp, purchase_stock: RR check forecasted stock When the scheduler runs, each orderpoint will check the forecast stock for an infinite time. As example, if we plan a receipt of 10 products in 4 months and need to deliver 5 products next month, procurement will not be launched since we have a forecasted quantity of 5. In order to improve it, the orderpoint will compute the total lead days from rules, suppliers/production delays, security days,... Those lead days will be added to today in order to know at which date the forecast stock should be checked and trigger the replenish document for today in order to be delivered at the correct date. Task: 1849912 --- addons/mrp/models/stock_rule.py | 19 ++++++++ addons/purchase_stock/models/stock_rule.py | 18 ++++++++ addons/stock/__manifest__.py | 1 + addons/stock/models/product.py | 12 +++++ addons/stock/models/stock_orderpoint.py | 23 +++++++++- addons/stock/models/stock_rule.py | 15 ++++++- .../xml/stock_orderpoint_lead_days_widget.xml | 29 ++++++++++++ addons/stock/tests/test_proc_rule.py | 44 ++++++++++++++++++- addons/stock/views/stock_warehouse_views.xml | 1 + 9 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 addons/stock/static/src/xml/stock_orderpoint_lead_days_widget.xml diff --git a/addons/mrp/models/stock_rule.py b/addons/mrp/models/stock_rule.py index 22b5cd3bd65..b9a285c5c4a 100644 --- a/addons/mrp/models/stock_rule.py +++ b/addons/mrp/models/stock_rule.py @@ -110,6 +110,25 @@ class StockRule(models.Model): date_planned = date_planned - relativedelta(days=company_id.manufacturing_lead) return date_planned + def _get_lead_days(self, product): + """Add the product and company manufacture delay to the cumulative delay + and cumulative description. + """ + delay, delay_description = super()._get_lead_days(product) + manufacture_rule = self.filtered(lambda r: r.action == 'manufacture') + if not manufacture_rule: + return delay, delay_description + manufacture_rule.ensure_one() + manufacture_delay = product.produce_delay + if manufacture_delay: + delay += manufacture_delay + delay_description += '%s+ %d %s' % (_('Manufacturing Lead Time'), manufacture_delay, _('day(s)')) + security_delay = manufacture_rule.picking_type_id.company_id.manufacturing_lead + if security_delay: + delay += security_delay + delay_description += '%s+ %d %s' % (_('Company Manufacture Lead Time'), security_delay, _('day(s)')) + return delay, delay_description + def _push_prepare_move_copy_values(self, move_to_copy, new_date): new_move_vals = super(StockRule, self)._push_prepare_move_copy_values(move_to_copy, new_date) new_move_vals['production_id'] = False diff --git a/addons/purchase_stock/models/stock_rule.py b/addons/purchase_stock/models/stock_rule.py index 7f8158ed829..93624ea7cc6 100644 --- a/addons/purchase_stock/models/stock_rule.py +++ b/addons/purchase_stock/models/stock_rule.py @@ -128,6 +128,24 @@ class StockRule(models.Model): procurement.values, po)) self.env['purchase.order.line'].sudo().create(po_line_values) + def _get_lead_days(self, product): + """Add the company security lead time, days to purchase and the supplier + delay to the cumulative delay and cumulative description. The days to + purchase and company lead time are always displayed for onboarding + purpose in order to indicate that those options are available. + """ + delay, delay_description = super()._get_lead_days(product) + buy_rule = self.filtered(lambda r: r.action == 'buy') + if not buy_rule or not product._prepare_sellers(False): + return delay, delay_description + buy_rule.ensure_one() + supplier_delay = product._prepare_sellers()[0].delay + if supplier_delay: + delay_description += '%s+ %d %s' % (_('Vendor Lead Time'), supplier_delay, _('day(s)')) + security_delay = buy_rule.picking_type_id.company_id.po_lead + delay_description += '%s+ %d %s' % (_('Purchase Security Lead Time'), security_delay, _('day(s)')) + return delay + supplier_delay + security_delay, delay_description + @api.model def _get_procurements_to_merge_groupby(self, procurement): # Do not group procument from different orderpoint. 1. _quantity_in_progress diff --git a/addons/stock/__manifest__.py b/addons/stock/__manifest__.py index 98ba3d074f7..8991029f641 100644 --- a/addons/stock/__manifest__.py +++ b/addons/stock/__manifest__.py @@ -80,6 +80,7 @@ 'static/src/xml/inventory_report.xml', 'static/src/xml/inventory_lines.xml', 'static/src/xml/popover_widget.xml', + 'static/src/xml/stock_orderpoint_lead_days_widget.xml', 'static/src/xml/stock_traceability_report_backend.xml', ], 'installable': True, diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index f5056f25894..df95ce00d43 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -523,6 +523,18 @@ class Product(models.Model): raise UserError(msg) return res + def _get_rules_from_location(self, location, seen_rules=False): + if not seen_rules: + seen_rules = self.env['stock.rule'] + rule = self.env['procurement.group']._get_rule(self, location, {'warehouse_id': location.get_warehouse()}) + if not rule: + return seen_rules + if rule.procure_method == 'make_to_stock' or rule.action not in ('pull_push', 'pull'): + return seen_rules | rule + else: + return self._get_rules_from_location(rule.location_src_id, seen_rules | rule) + + class ProductTemplate(models.Model): _inherit = 'product.template' _check_company_auto = True diff --git a/addons/stock/models/stock_orderpoint.py b/addons/stock/models/stock_orderpoint.py index e4369e169d2..b805de95ecb 100644 --- a/addons/stock/models/stock_orderpoint.py +++ b/addons/stock/models/stock_orderpoint.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from datetime import datetime +from datetime import datetime, time from dateutil import relativedelta +from json import dumps from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError @@ -64,6 +65,9 @@ class StockWarehouseOrderpoint(models.Model): default=lambda self: self.env.company) allowed_location_ids = fields.One2many(comodel_name='stock.location', compute='_compute_allowed_location_ids') + json_lead_days_popover = fields.Char(compute='_compute_lead_days') + lead_days_date = fields.Date(compute='_compute_lead_days') + _sql_constraints = [ ('qty_multiple_check', 'CHECK( qty_multiple >= 0 )', 'Qty Multiple must be greater than or equal to zero.'), ] @@ -81,6 +85,22 @@ class StockWarehouseOrderpoint(models.Model): loc_domain = expression.AND([loc_domain, ['|', ('company_id', '=', False), ('company_id', '=', orderpoint.company_id.id)]]) orderpoint.allowed_location_ids = self.env['stock.location'].search(loc_domain) + @api.depends('product_id', 'location_id', 'company_id', 'warehouse_id', + 'product_id.seller_ids', 'product_id.seller_ids.delay') + def _compute_lead_days(self): + for orderpoint in self: + rules = orderpoint.product_id._get_rules_from_location(orderpoint.location_id) + lead_days, lead_days_description = rules._get_lead_days(orderpoint.product_id) + lead_days_date = fields.Date.today() + relativedelta.relativedelta(days=lead_days) + orderpoint.json_lead_days_popover = dumps({ + 'title': _('Replenishment'), + 'popoverTemplate': 'stock.leadDaysPopOver', + 'lead_days_date': fields.Date.to_string(lead_days_date), + 'lead_days_description': lead_days_description, + 'today': fields.Date.to_string(fields.Date.today()), + }) + orderpoint.lead_days_date = lead_days_date + def _quantity_in_progress(self): """Return Quantities that are not yet in virtual stock but should be deduced from orderpoint rule (example: purchases created from orderpoints)""" @@ -122,6 +142,7 @@ class StockWarehouseOrderpoint(models.Model): self.ensure_one() return { 'location': self.location_id.id, + 'to_date': datetime.combine(self.lead_days_date, time.max) } def _prepare_procurement_values(self, product_qty, date=False, group=False): diff --git a/addons/stock/models/stock_rule.py b/addons/stock/models/stock_rule.py index c1a05a3fae2..77b31e830d2 100644 --- a/addons/stock/models/stock_rule.py +++ b/addons/stock/models/stock_rule.py @@ -9,7 +9,7 @@ from psycopg2 import OperationalError from odoo import api, fields, models, registry, SUPERUSER_ID, _ from odoo.osv import expression -from odoo.tools import float_compare, float_round, frozendict +from odoo.tools import float_compare, float_round, frozendict, html_escape from odoo.exceptions import UserError import logging @@ -323,6 +323,19 @@ class StockRule(models.Model): user_id=product_id.responsible_id.id or SUPERUSER_ID, ) + def _get_lead_days(self, product): + """Returns the cumulative delay and its description encountered by a + procurement going through the rules in `self`. + + :param product: the product of the procurement + :type product: :class:`~odoo.addons.product.models.product.ProductProduct` + :return: the cumulative delay and cumulative delay's description + :rtype: tuple + """ + delay = sum(self.filtered(lambda r: r.action in ['pull', 'pull_push']).mapped('delay')) + delay_description = ''.join(['%s %s+ %d %s' % (_('Delay on'), html_escape(rule.name), rule.delay, _('day(s)')) for rule in self if rule.action in ['pull', 'pull_push'] and rule.delay]) + return delay, delay_description + class ProcurementGroup(models.Model): """ diff --git a/addons/stock/static/src/xml/stock_orderpoint_lead_days_widget.xml b/addons/stock/static/src/xml/stock_orderpoint_lead_days_widget.xml new file mode 100644 index 00000000000..51214708a24 --- /dev/null +++ b/addons/stock/static/src/xml/stock_orderpoint_lead_days_widget.xml @@ -0,0 +1,29 @@ + + +
+

+ If the forecasted stock on the is below the minimum quantity, replenishment will be triggered. +

+ + + + + + + + + + + + +
+ Today + + +
+ Forecasted Date + + = +
+
+
diff --git a/addons/stock/tests/test_proc_rule.py b/addons/stock/tests/test_proc_rule.py index 334213ddeb2..2ea7b6c0331 100644 --- a/addons/stock/tests/test_proc_rule.py +++ b/addons/stock/tests/test_proc_rule.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta -from odoo.tests.common import TransactionCase +from odoo.tests.common import Form, TransactionCase from odoo.tools import mute_logger @@ -167,6 +167,46 @@ class TestProcRule(TransactionCase): self.assertAlmostEqual(move_orig.date, datetime.now(), delta=timedelta(seconds=10), msg='date should be now') self.assertAlmostEqual(move_dest.date_expected, move_dest_initial_date + timedelta(days=6), delta=timedelta(seconds=10), msg='date should be propagated') + def test_reordering_rule_1(self): + warehouse = self.env['stock.warehouse'].search([], limit=1) + orderpoint_form = Form(self.env['stock.warehouse.orderpoint']) + orderpoint_form.product_id = self.product + orderpoint_form.location_id = warehouse.lot_stock_id + orderpoint_form.product_min_qty = 0.0 + orderpoint_form.product_max_qty = 5.0 + orderpoint_form.save() + + self.env['stock.rule'].create({ + 'name': 'Rule Supplier', + 'route_id': warehouse.reception_route_id.id, + 'location_id': warehouse.lot_stock_id.id, + 'location_src_id': self.env.ref('stock.stock_location_suppliers').id, + 'action': 'pull', + 'delay': 9.0, + 'procure_method': 'make_to_stock', + 'picking_type_id': warehouse.in_type_id.id, + }) + + delivery_move = self.env['stock.move'].create({ + 'name': 'Delivery', + 'date_expected': datetime.today() + timedelta(days=5), + 'product_id': self.product.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 12.0, + 'location_id': warehouse.lot_stock_id.id, + 'location_dest_id': self.ref('stock.stock_location_customers'), + }) + delivery_move._action_confirm() + self.env['procurement.group'].run_scheduler() + + receipt_move = self.env['stock.move'].search([ + ('product_id', '=', self.product.id), + ('location_id', '=', self.env.ref('stock.stock_location_suppliers').id) + ]) + self.assertTrue(receipt_move) + self.assertEqual(receipt_move.date_expected.date(), date.today()) + self.assertEqual(receipt_move.product_uom_qty, 17.0) + class TestProcRuleLoad(TransactionCase): def setUp(cls): diff --git a/addons/stock/views/stock_warehouse_views.xml b/addons/stock/views/stock_warehouse_views.xml index 434a32d31b0..8cc27320955 100644 --- a/addons/stock/views/stock_warehouse_views.xml +++ b/addons/stock/views/stock_warehouse_views.xml @@ -225,6 +225,7 @@
+