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 @@
-
+
+