diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index 1ba70e5d716..331b744397d 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -228,6 +228,11 @@ class StockMove(models.Model): else: return super(StockMove, self)._get_upstream_documents_and_responsibles(visited) + def _delay_alert_get_documents(self): + res = super(StockMove, self)._delay_alert_get_documents() + productions = self.mapped('raw_material_production_id') + return res + list(productions) + def _should_be_assigned(self): res = super(StockMove, self)._should_be_assigned() return bool(res and not (self.production_id or self.raw_material_production_id)) diff --git a/addons/purchase_stock/tests/test_stockvaluation.py b/addons/purchase_stock/tests/test_stockvaluation.py index 14383c79b57..07cf6272ca9 100644 --- a/addons/purchase_stock/tests/test_stockvaluation.py +++ b/addons/purchase_stock/tests/test_stockvaluation.py @@ -824,10 +824,10 @@ class TestStockValuationWithCOA(AccountingTestCase): # To allow testing validation of PO def _today(*args, **kwargs): - return date_po + return datetime.strptime(date_po, "%Y-%m-%d").date() # To allow testing validation of Delivery def _now(*args, **kwargs): - return date_delivery + ' 01:00:00' + return datetime.strptime(date_delivery + ' 01:00:00', "%Y-%m-%d %H:%M:%S") patchers = [ patch('odoo.fields.Date.context_today', _today), @@ -993,11 +993,11 @@ class TestStockValuationWithCOA(AccountingTestCase): # To allow testing validation of PO def _today(*args, **kwargs): - return date_po + return datetime.strptime(date_po, "%Y-%m-%d").date() # To allow testing validation of Delivery delivery_now = date_delivery def _now(*args, **kwargs): - return delivery_now + ' 01:00:00' + return datetime.strptime(delivery_now + ' 01:00:00', "%Y-%m-%d %H:%M:%S") patchers = [ patch('odoo.fields.Date.context_today', _today), diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 2dbdbc44ed4..a62a2959e23 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -7,7 +7,7 @@ from itertools import groupby from operator import itemgetter from re import search as regex_search, split as regex_split -from odoo import api, fields, models, _ +from odoo import api, fields, models, _, SUPERUSER_ID from odoo.exceptions import UserError from odoo.osv import expression from odoo.tools.float_utils import float_compare, float_round, float_is_zero @@ -131,6 +131,7 @@ class StockMove(models.Model): help='The rescheduling is propagated to the next move.') propagate_date_minimum_delta = fields.Integer(string='Reschedule if Higher Than', help='The change must be higher than this value to be propagated') + delay_alert = fields.Boolean('Alert if Delay') picking_type_id = fields.Many2one('stock.picking.type', 'Operation Type') inventory_id = fields.Many2one('stock.inventory', 'Inventory') move_line_ids = fields.One2many('stock.move.line', 'move_id') @@ -431,15 +432,19 @@ class StockMove(models.Model): if propagated_date_field: #any propagation is (maybe) needed + new_date = vals.get(propagated_date_field) for move in self: - if move.move_dest_ids and move.propagate_date: - new_date = vals.get(propagated_date_field) - delta_days = (new_date - move.date_expected).total_seconds() / 86400 - if abs(delta_days) < move.propagate_date_minimum_delta: - continue - for move_dest in move.move_dest_ids: - if move_dest.state not in ('done', 'cancel'): - move_dest.date_expected += relativedelta.relativedelta(days=delta_days) + move_dest_ids = move.move_dest_ids.filtered(lambda m: m.state not in ('done', 'cancel')) + if not move_dest_ids: + continue + delta_days = (new_date - move.date_expected).total_seconds() / 86400 + if not move.propagate_date or abs(delta_days) < move.propagate_date_minimum_delta: + move_dest_ids._delay_alert_log_activity('manual', move) + continue + for move_dest in move_dest_ids: + move_dest.date_expected += relativedelta.relativedelta(days=delta_days) + move_dest_ids._delay_alert_log_activity('auto', move) + track_pickings = not self._context.get('mail_notrack') and any(field in vals for field in ['state', 'picking_id', 'partially_available']) if track_pickings: to_track_picking_ids = set([move.picking_id.id for move in self if move.picking_id]) @@ -455,6 +460,47 @@ class StockMove(models.Model): receipt_moves_to_reassign._action_assign() return res + def _delay_alert_get_documents(self): + """Returns a list of recordset of the documents linked to the stock.move in `self` in order + to post the delay alert next activity. These documents are deduplicated. This method is meant + to be overriden by other modules, each of them adding an element by type of recordset on + this list. + + :return: a list of recordset of the documents linked to `self` + :rtype: list + """ + return list(self.mapped('picking_id')) + + def _delay_alert_log_activity(self, mode, move_orig): + """Post a delay alert next activity on the documents linked to `self`. If the delay alert + is already present on the document, it isn't posted twice. + + :param mode: 'auto' or 'manual' as a string + :param move_orig: the stock move triggering the delay alert on the next document + """ + assert mode in ('auto', 'manual') + + doc_orig = move_orig._delay_alert_get_documents() + documents = self.filtered(lambda m: m.delay_alert)._delay_alert_get_documents() + if not documents or not doc_orig: + return + + if mode == 'auto': + msg = _("The scheduled date has been automatically updated due to a delay on %s.") % (doc_orig[0]._name, doc_orig[0].id, doc_orig[0].name) + else: + msg = _("The scheduled date should be updated due to a delay on %s.") % (doc_orig[0]._name, doc_orig[0].id, doc_orig[0].name) + + # write the message on each document + for doc in documents: + if doc.activity_ids.filtered(lambda a: a.automated and doc_orig[0].name in a.note): + continue + doc.activity_schedule( + 'mail.mail_activity_data_warning', + datetime.today().date(), + note=msg, + user_id=doc.user_id.id or SUPERUSER_ID + ) + def action_show_details(self): """ Returns an action that will open a form view (in a popup) allowing to work on all the move lines of a particular move. This form view is used when "show operations" is not @@ -592,7 +638,8 @@ class StockMove(models.Model): return [ 'product_id', 'price_unit', 'product_packaging', 'procure_method', 'product_uom', 'restrict_partner_id', 'scrapped', 'origin_returned_move_id', - 'package_level_id' + 'package_level_id', 'propagate_cancel', 'propagate_date', 'propagate_date_minimum_delta', + 'delay_alert', ] @api.model @@ -601,7 +648,8 @@ class StockMove(models.Model): return [ move.product_id.id, move.price_unit, move.product_packaging.id, move.procure_method, move.product_uom.id, move.restrict_partner_id.id, move.scrapped, move.origin_returned_move_id.id, - move.package_level_id.id + move.package_level_id.id, move.propagate_cancel, move.propagate_date, move.propagate_date_minimum_delta, + move.delay_alert, ] def _clean_merged(self): diff --git a/addons/stock/models/stock_rule.py b/addons/stock/models/stock_rule.py index 68d16de19a9..76e0927c738 100644 --- a/addons/stock/models/stock_rule.py +++ b/addons/stock/models/stock_rule.py @@ -74,14 +74,21 @@ class StockRule(models.Model): help='The rescheduling is propagated to the next move.') propagate_date_minimum_delta = fields.Integer(string='Reschedule if Higher Than', help='The change must be higher than this value to be propagated', default=1) + delay_alert = fields.Boolean( + 'Alert if Delay', + help='Log an exception on the picking if this move has to be delayed (due to a change in the previous move scheduled date).', + ) @api.onchange('picking_type_id') def _onchange_picking_type(self): """ Modify locations to the default picking type's locations source and destination. + Enable the delay alert if the picking type is a delivery """ self.location_src_id = self.picking_type_id.default_location_src_id.id self.location_id = self.picking_type_id.default_location_dest_id.id + if self.picking_type_id.code == 'outgoing': + self.delay_alert = True @api.onchange('route_id', 'company_id') def _onchange_route(self): @@ -172,6 +179,7 @@ class StockRule(models.Model): 'picking_type_id': self.picking_type_id.id, 'propagate_cancel': self.propagate_cancel, 'warehouse_id': self.warehouse_id.id, + 'delay_alert': self.delay_alert } return new_move_vals @@ -270,6 +278,7 @@ class StockRule(models.Model): 'propagate_date_minimum_delta': self.propagate_date_minimum_delta, 'description_picking': product_id._get_description(self.picking_type_id), 'priority': values.get('priority', "1"), + 'delay_alert': self.delay_alert, } for field in self._get_custom_move_fields(): if field in values: diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 4858937723b..9431c879c49 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -340,6 +340,7 @@ class Warehouse(models.Model): 'company_id': self.company_id.id, 'action': 'pull', 'auto': 'manual', + 'delay_alert': True, 'route_id': self._find_global_route('stock.route_warehouse0_mto', _('Make To Order')).id }, 'update_values': { @@ -677,6 +678,7 @@ class Warehouse(models.Model): 'procure_method': first_rule and 'make_to_stock' or 'make_to_order', 'warehouse_id': self.id, 'company_id': self.company_id.id, + 'delay_alert': routing.picking_type.code == 'outgoing', } route_rule_values.update(values or {}) rules_list.append(route_rule_values) diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py index 635d1b6c121..d31fe2f505e 100644 --- a/addons/stock/tests/test_move2.py +++ b/addons/stock/tests/test_move2.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import timedelta + from odoo.addons.stock.tests.common import TestStockCommon from odoo.exceptions import UserError from odoo.tests import Form @@ -1896,8 +1898,8 @@ class TestRoutes(TestStockCommon): # create and get back the pick ship route self.wh.write({'delivery_steps': 'pick_ship'}) - self.pick_ship_route = self.wh.route_ids.filtered(lambda r: '(pick + ship)' in r.name) + def test_pick_ship_1(self): """ Enable the pick ship route, force a procurement group on the pick. When a second move is added, make sure the `partner_id` and @@ -2225,3 +2227,146 @@ class TestRoutes(TestStockCommon): # All the moves should be should have the same quantity as it is on each procurements self.assertEquals(len(picking.move_lines), 1) self.assertEquals(picking.move_lines.product_uom_qty, 2) + + def test_delay_alert_1(self): + """ On a pick pack ship scenario, enable the delay alert flag on the pack rule. Edit the + schedule date on the pick, a delay alert should be created for the ship. + by default: + - delay alert set is only ship rule + - propagate date is True on all the pick-pack-ship rules + """ + warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1) + warehouse.delivery_steps = 'pick_pack_ship' + partner_demo_customer = self.env.ref('base.res_partner_1') + final_location = partner_demo_customer.property_stock_customer + product_a = self.env['product.product'].create({ + 'name': 'ProductA', + 'type': 'product', + }) + product_b = self.env['product.product'].create({ + 'name': 'ProductA', + 'type': 'product', + }) + + pg = self.env['procurement.group'].create({'name': 'Test-delay_alert'}) + + self.env['procurement.group'].run([ + pg.Procurement( + product_a, + 4.0, + product_a.uom_id, + final_location, + 'delay', + 'delay', + warehouse.company_id, + { + 'warehouse_id': warehouse, + 'group_id': pg + } + ), + pg.Procurement( + product_b, + 4.0, + product_a.uom_id, + final_location, + 'delay', + 'delay', + warehouse.company_id, + { + 'warehouse_id': warehouse, + 'group_id': pg + } + ) + ]) + first_move = self.env['stock.move'].search([ + ('product_id', '=', product_a.id), + ('state', '=', 'confirmed') + ]) + + # Change the schedule date on the pick. + first_move.picking_id.scheduled_date += timedelta(days=2) + + # No activity should be created on the pack. + activity = first_move.move_dest_ids.picking_id.activity_ids # PACK + self.assertFalse(activity) + + # An activity is created on the ship. + activity = first_move.move_dest_ids.move_dest_ids.picking_id.activity_ids # SHIP + self.assertTrue(activity, '') + self.assertTrue('has been automatically' in activity.note) + + # Change second time the schedule date on the pick. + first_move.picking_id.scheduled_date += timedelta(days=2) + activity = first_move.move_dest_ids.move_dest_ids.picking_id.activity_ids # SHIP + self.assertEqual(len(activity), 1) + + def test_delay_alert_2(self): + """ On a pick ship scenario, two pick linked to a ship. The delay alert is set on the ship rule? + When editing the schedule date on the two pick, two delay alerts activty should be created + on the pack. + """ + self._enable_pick_ship() + + # create a procurement group and set in on the pick stock rule + procurement_group0 = self.env['procurement.group'].create({}) + procurement_group1 = self.env['procurement.group'].create({}) + pick_rule = self.pick_ship_route.rule_ids.filtered(lambda rule: 'Stock → Output' in rule.name) + ship_rule = self.pick_ship_route.rule_ids - pick_rule + ship_rule.write({ + 'group_propagation_option': 'fixed', + 'group_id': procurement_group0.id, + }) + + ship_location = pick_rule.location_id + customer_location = ship_rule.location_id + product1 = self.env['product.product'].create({'name': 'product1'}) + product2 = self.env['product.product'].create({'name': 'product2'}) + + picking_ship = self.env['stock.picking'].create({ + 'location_id': ship_location.id, + 'location_dest_id': self.customer_location, + 'partner_id': self.partner_delta_id, + 'picking_type_id': self.picking_type_out, + }) + + move1 = self.env['stock.move'].create({ + 'name': 'first out move', + 'procure_method': 'make_to_order', + 'location_id': ship_location.id, + 'location_dest_id': customer_location.id, + 'product_id': product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + 'warehouse_id': self.wh.id, + 'group_id': procurement_group0.id, + 'origin': 'origin1', + 'picking_id': picking_ship.id, + 'delay_alert': True, + }) + + move2 = self.env['stock.move'].create({ + 'name': 'second out move', + 'procure_method': 'make_to_order', + 'location_id': ship_location.id, + 'location_dest_id': customer_location.id, + 'product_id': product2.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + 'warehouse_id': self.wh.id, + 'group_id': procurement_group1.id, + 'origin': 'origin2', + 'picking_id': picking_ship.id, + 'delay_alert': True, + }) + + # confirm the picking to create the orig moves + picking_ship.action_confirm() + picking_pick_1 = move1.move_orig_ids.picking_id + picking_pick_2 = move2.move_orig_ids.picking_id + + picking_pick_1.scheduled_date += timedelta(days=2) + picking_pick_2.scheduled_date += timedelta(days=2) + activity = picking_ship.activity_ids + self.assertEqual(len(activity), 2, 'not enough activity created') + self.assertTrue(picking_pick_1.name in activity[0].note, 'Wrong activity message') + self.assertTrue(picking_pick_2.name in activity[1].note, 'Wrong activity message') diff --git a/addons/stock/views/stock_rule_views.xml b/addons/stock/views/stock_rule_views.xml index 97d99330e4e..b4511d917b9 100644 --- a/addons/stock/views/stock_rule_views.xml +++ b/addons/stock/views/stock_rule_views.xml @@ -104,7 +104,8 @@ - + +