diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 56807a26c93..1b5b790178e 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -743,9 +743,6 @@ class MrpProduction(models.Model): order._plan_workorders() return True - def _get_start_date(self): - return self.date_planned_start or datetime.datetime.now() - def _plan_workorders(self, replan=False): """ Plan all the production's workorders depending on the workcenters work schedule. @@ -758,9 +755,12 @@ class MrpProduction(models.Model): # Schedule all work orders (new ones and those already created) qty_to_produce = max(self.product_qty - self.qty_produced, 0) qty_to_produce = self.product_uom_id._compute_quantity(qty_to_produce, self.product_id.uom_id) - start_date = self._get_start_date() + start_date = self.date_planned_start if replan: workorder_ids = self.workorder_ids.filtered(lambda wo: wo.state in ['ready', 'pending']) + # We plan the manufacturing order according to its `date_planned_start`, but if + # `date_planned_start` is in the past, we plan it as soon as possible. + start_date = max(start_date, datetime.datetime.now()) workorder_ids.leave_id.unlink() else: workorder_ids = self.workorder_ids @@ -775,14 +775,10 @@ class MrpProduction(models.Model): cycle_number = float_round(qty_to_produce / workcenter.capacity, precision_digits=0, rounding_method='UP') duration_expected = workcenter.time_start + workcenter.time_stop + cycle_number * time_cycle * 100.0 / workcenter.time_efficiency - # get first free slot - # planning 0 hours gives the start of the next attendance - from_date = workcenter.resource_calendar_id.plan_hours(0, start_date, compute_leaves=True, resource=workcenter.resource_id, domain=[('time_type', 'in', ['leave', 'other'])]) + from_date, to_date = workcenter._get_first_available_slot(start_date, duration_expected) # If the workcenter is unavailable, try planning on the next one - if from_date is False: + if not from_date: continue - to_date = workcenter.resource_calendar_id.plan_hours(duration_expected / 60.0, from_date, compute_leaves=True, resource=workcenter.resource_id, domain=[('time_type', 'in', ['leave', 'other'])]) - # Check if this workcenter is better than the previous ones if to_date and to_date < best_finished_date: best_start_date = from_date diff --git a/addons/mrp/models/mrp_workcenter.py b/addons/mrp/models/mrp_workcenter.py index cae9181da48..fe3ea2a9db5 100644 --- a/addons/mrp/models/mrp_workcenter.py +++ b/addons/mrp/models/mrp_workcenter.py @@ -2,10 +2,14 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from dateutil import relativedelta +from datetime import timedelta +from functools import partial import datetime from odoo import api, exceptions, fields, models, _ from odoo.exceptions import ValidationError +from odoo.addons.resource.models.resource import make_aware, Intervals +from odoo.tools.float_utils import float_compare class MrpWorkcenter(models.Model): @@ -204,6 +208,47 @@ class MrpWorkcenter(models.Model): unavailability_ressources = self.resource_id._get_unavailable_intervals(start_datetime, end_datetime) return {wc.id: unavailability_ressources.get(wc.resource_id.id, []) for wc in self} + def _get_first_available_slot(self, start_datetime, duration): + """Get the first available interval for the workcenter in `self`. + + The available interval is disjoinct with all other workorders planned on this workcenter, but + can overlap the time-off of the related calendar (inverse of the working hours). + Return the first available interval (start datetime, end datetime) or, + if there is none before 700 days, a tuple error (False, 'error message'). + + :param start_datetime: begin the search at this datetime + :param duration: minutes needed to make the workorder (float) + :rtype: tuple + """ + self.ensure_one() + start_datetime, revert = make_aware(start_datetime) + + get_available_intervals = partial(self.resource_calendar_id._work_intervals, domain=[('time_type', 'in', ['other', 'leave'])], resource=self.resource_id) + get_workorder_intervals = partial(self.resource_calendar_id._leave_intervals, domain=[('time_type', '=', 'other')], resource=self.resource_id) + + remaining = duration + start_interval = start_datetime + delta = timedelta(days=14) + + for n in range(50): # 50 * 14 = 700 days in advance (hardcoded) + dt = start_datetime + delta * n + available_intervals = get_available_intervals(dt, dt + delta) + workorder_intervals = get_workorder_intervals(dt, dt + delta) + for start, stop, dummy in available_intervals: + interval_minutes = (stop - start).total_seconds() / 60 + # If the remaining minutes has never decrease update start_interval + if remaining == duration: + start_interval = start + # If there is a overlap between the possible available interval and a others WO + if Intervals([(start_interval, start + timedelta(minutes=min(remaining, interval_minutes)), dummy)]) & workorder_intervals: + remaining = duration + start_interval = start + elif float_compare(interval_minutes, remaining, precision_digits=3) >= 0: + return revert(start_interval), revert(start + timedelta(minutes=remaining)) + # Decrease a part of the remaining duration + remaining -= interval_minutes + return False, 'Not available slot 700 days after the planned start' + class MrpWorkcenterProductivityLossType(models.Model): _name = "mrp.workcenter.productivity.loss.type" diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index 7fa30626a6e..9e648d78603 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -159,7 +159,7 @@ class MrpWorkorder(models.Model): previous_wo = previous_wo_dict.get(wo.id) prev_start = previous_wo and previous_wo['date_planned_start'] or False prev_finished = previous_wo and previous_wo['date_planned_finished'] or False - if wo.state == 'pending' and prev_start and not (prev_start > wo.date_planned_finished): + if wo.state == 'pending' and prev_start and not (prev_start > wo.date_planned_start): infos.append({ 'color': 'text-primary', 'msg': _("Waiting the previous work order, planned from %s to %s") % ( @@ -171,7 +171,7 @@ class MrpWorkorder(models.Model): 'color': 'text-warning', 'msg': _("The work order should have already been processed.") }) - if prev_start and prev_start > wo.date_planned_finished: + if prev_start and prev_start > wo.date_planned_start: infos.append({ 'color': 'text-danger', 'msg': _("Scheduled before the previous work order, planned from %s to %s") % ( diff --git a/addons/mrp/tests/test_workorder_operation.py b/addons/mrp/tests/test_workorder_operation.py index 908c3b3e2e6..1d0dd9ca0cf 100644 --- a/addons/mrp/tests/test_workorder_operation.py +++ b/addons/mrp/tests/test_workorder_operation.py @@ -1023,6 +1023,52 @@ class TestWorkOrderProcess(TestWorkOrderProcessCommon): self.assertAlmostEqual(workorder.date_planned_start, date_start, delta=timedelta(seconds=1), msg="Workorder should be planned tomorrow.") self.assertAlmostEqual(workorder.date_planned_finished, date_start + timedelta(hours=1), delta=timedelta(seconds=1), msg="Workorder should be done one hour later.") + def test_planning_overlaps_wo(self): + """ Test that workorder doesn't overlaps between then when plan the MO """ + self.full_availability() + + dining_table = self.dining_table + + # Take between +30min -> +90min + date_start = datetime.now() + timedelta(minutes=30) + + production_table_form = Form(self.env['mrp.production']) + production_table_form.product_id = dining_table + production_table_form.bom_id = self.mrp_bom_desk + production_table_form.product_qty = 1.0 + production_table_form.product_uom_id = dining_table.uom_id + production_table_form.date_planned_start = date_start + production_table = production_table_form.save() + production_table.action_confirm() + + # Create work order + production_table.button_plan() + workorder_prev = production_table.workorder_ids[0] + + # Check that the workorder is planned now and that it lasts one hour + self.assertAlmostEqual(workorder_prev.date_planned_start, date_start, delta=timedelta(seconds=10), msg="Workorder should be planned in +30min") + self.assertAlmostEqual(workorder_prev.date_planned_finished, date_start + timedelta(hours=1), delta=timedelta(seconds=10), msg="Workorder should be done in +90min") + + # As soon as possible, but because of the first one, it will planned only after +90 min + date_start = datetime.now() + + production_table_form = Form(self.env['mrp.production']) + production_table_form.product_id = dining_table + production_table_form.bom_id = self.mrp_bom_desk + production_table_form.product_qty = 1.0 + production_table_form.product_uom_id = dining_table.uom_id + production_table_form.date_planned_start = date_start + production_table = production_table_form.save() + production_table.action_confirm() + + # Create work order + production_table.button_plan() + workorder = production_table.workorder_ids[0] + + # Check that the workorder is planned now and that it lasts one hour + self.assertAlmostEqual(workorder.date_planned_start, workorder_prev.date_planned_finished, delta=timedelta(seconds=10), msg="Workorder should be planned after the first one") + self.assertAlmostEqual(workorder.date_planned_finished, workorder_prev.date_planned_finished + timedelta(hours=1), delta=timedelta(seconds=10), msg="Workorder should be done one hour later.") + def test_change_production_1(self): """Change the quantity to produce on the MO while workorders are already planned.""" dining_table = self.dining_table @@ -1369,7 +1415,7 @@ class TestWorkOrderProcess(TestWorkOrderProcessCommon): self.assertEqual(line3.product_id, self.product_1) self.assertEqual(line3.qty_done, 4) - def test_replan(self): + def test_conflict_and_replan(self): """ TEST Json data conflicted and the replan button of a workorder """ self.routing_1.operation_ids[0].write({'workcenter_id': self.mrp_workcenter_3.id}) dining_table = self.dining_table