[IMP] mrp(_subcontracting), stock: MO reserve and replenish automatically

This commit does 3 things:

1. When 'action_confirm' is triggered in a manufacturing order, any
   'move_raw_ids' (components) that are unable to be fully reserved
   when `action_assign` is triggered will be checked for an automatic
   reordering rule (RR). If a rule exists it will be auto-triggered.
   This includes subcontractor manufacturing orders. Relevant test
   updated to match this.
2. When a MO is completed, auto check if there are corresponding
   confirmed stock moves that can be populated by the completed products
   and trigger their 'action_assign' to reserve the completed products.
3. If a new move line is added to an already confirmed MO, then do same
   auto-RR check/trigger. (note will occur for pickings without extra
   code since `stock_picking.action_confirm` is triggered again in this
   case.)

This reuses similar logic as odoo/odoo#52433 from task 2244230.
Originally this was done in the `action_assign`, but has been moved to
`action_confirm` so as to imitate the now archived MTO process with RR.
Overlapping logic has been moved into stock_move for easier reusability.
Note that this now means editing existing moves' `product_uom_qty` (i.e.
Demand) will not have a flow to auto-trigger their RR.

Subcontracting test updated to match + new mrp test added for this new
feature.

closes odoo/odoo#57334

Task: 2322496
X-original-commit: 2688cd1bae489b1dec8862f4de727ac20514844c
Signed-off-by: Arnold Moyaux <amoyaux@users.noreply.github.com>
This commit is contained in:
Tiffany Chang (tic)
2020-09-09 09:21:55 +00:00
parent 2270406c48
commit e6255e09b8
6 changed files with 234 additions and 33 deletions
+8
View File
@@ -976,6 +976,8 @@ class MrpProduction(models.Model):
if moves_to_confirm:
moves_to_confirm._action_confirm()
# run scheduler for moves forecasted to not have enough in stock
moves_to_confirm._trigger_scheduler()
self.workorder_ids.filtered(lambda w: w.state not in ['done', 'cancel'])._action_confirm()
@@ -1074,6 +1076,8 @@ class MrpProduction(models.Model):
production.move_raw_ids._adjust_procure_method()
(production.move_raw_ids | production.move_finished_ids)._action_confirm()
production.workorder_ids._action_confirm()
# run scheduler for moves forecasted to not have enough in stock
production.move_raw_ids._trigger_scheduler()
return True
def action_assign(self):
@@ -1435,6 +1439,10 @@ class MrpProduction(models.Model):
productions_to_backorder._post_inventory(cancel_backorder=False)
backorders = productions_to_backorder._generate_backorder_productions()
# if completed products make other confirmed/partially_available moves available, assign them
done_move_finished_ids = (productions_to_backorder.move_finished_ids | productions_not_to_backorder.move_finished_ids).filtered(lambda m: m.state == 'done')
done_move_finished_ids._trigger_assign()
# Moves without quantity done are not posted => set them as done instead of canceling. In
# case the user edits the MO later on and sets some consumed quantity on those, we do not
# want the move lines to be canceled.
+158
View File
@@ -346,3 +346,161 @@ class TestProcurement(TestMrpCommon):
move_dest._action_assign()
self.assertEqual(move_dest.reserved_availability, 10.0)
def test_auto_assign(self):
""" When auto reordering rule exists, check for when:
1. There is not enough of a manufactured product to assign (reserve for) a picking => auto-create 1st MO
2. There is not enough of a manufactured component to assign the created MO => auto-create 2nd MO
3. Add an extra manufactured component (not in stock) to 1st MO => auto-create 3rd MO
4. When 2nd MO is completed => auto-assign to 1st MO
5. When 1st MO is completed => auto-assign to picking """
self.warehouse = self.env.ref('stock.warehouse0')
route_manufacture = self.warehouse.manufacture_pull_id.route_id
product_1 = self.env['product.product'].create({
'name': 'Cake',
'type': 'product',
'route_ids': [(6, 0, [route_manufacture.id])]
})
product_2 = self.env['product.product'].create({
'name': 'Cake Mix',
'type': 'product',
'route_ids': [(6, 0, [route_manufacture.id])]
})
product_3 = self.env['product.product'].create({
'name': 'Flour',
'type': 'consu',
})
self.env['mrp.bom'].create({
'product_id': product_1.id,
'product_tmpl_id': product_1.product_tmpl_id.id,
'product_uom_id': self.uom_unit.id,
'product_qty': 1,
'consumption': 'flexible',
'type': 'normal',
'bom_line_ids': [
(0, 0, {'product_id': product_2.id, 'product_qty': 1}),
]})
self.env['mrp.bom'].create({
'product_id': product_2.id,
'product_tmpl_id': product_2.product_tmpl_id.id,
'product_uom_id': self.uom_unit.id,
'product_qty': 1,
'type': 'normal',
'bom_line_ids': [
(0, 0, {'product_id': product_3.id, 'product_qty': 1}),
]})
# extra manufactured component added to 1st MO after it is already confirmed
product_4 = self.env['product.product'].create({
'name': 'Flavor Enchancer',
'type': 'product',
'route_ids': [(6, 0, [route_manufacture.id])]
})
product_5 = self.env['product.product'].create({
'name': 'MSG',
'type': 'consu',
})
self.env['mrp.bom'].create({
'product_id': product_4.id,
'product_tmpl_id': product_4.product_tmpl_id.id,
'product_uom_id': self.uom_unit.id,
'product_qty': 1,
'type': 'normal',
'bom_line_ids': [
(0, 0, {'product_id': product_5.id, 'product_qty': 1}),
]})
# setup auto orderpoints (reordering rules)
self.env['stock.warehouse.orderpoint'].create({
'name': 'Cake RR',
'location_id': self.warehouse.lot_stock_id.id,
'product_id': product_1.id,
'product_min_qty': 0,
'product_max_qty': 5,
})
self.env['stock.warehouse.orderpoint'].create({
'name': 'Cake Mix RR',
'location_id': self.warehouse.lot_stock_id.id,
'product_id': product_2.id,
'product_min_qty': 0,
'product_max_qty': 5,
})
self.env['stock.warehouse.orderpoint'].create({
'name': 'Flavor Enchancer RR',
'location_id': self.warehouse.lot_stock_id.id,
'product_id': product_4.id,
'product_min_qty': 0,
'product_max_qty': 5,
})
# create picking output to trigger creating MO for reordering product_1
pick_output = self.env['stock.picking'].create({
'name': 'Cake Delivery Order',
'picking_type_id': self.ref('stock.picking_type_out'),
'location_id': self.warehouse.lot_stock_id.id,
'location_dest_id': self.ref('stock.stock_location_customers'),
'move_lines': [(0, 0, {
'name': '/',
'product_id': product_1.id,
'product_uom': product_1.uom_id.id,
'product_uom_qty': 10.00,
'procure_method': 'make_to_stock',
})],
})
pick_output.action_confirm() # should trigger orderpoint to create and confirm 1st MO
pick_output.action_assign()
mo = self.env['mrp.production'].search([
('product_id', '=', product_1.id),
('state', '=', 'confirmed')
])
self.assertEqual(len(mo), 1, "Manufacture order was not automatically created")
mo.action_assign()
self.assertEqual(mo.move_raw_ids.reserved_availability, 0, "No components should be reserved yet")
self.assertEqual(mo.product_qty, 15, "Quantity to produce should be picking demand + reordering rule max qty")
# 2nd MO for product_2 should have been created and confirmed when 1st MO for product_1 was confirmed
mo2 = self.env['mrp.production'].search([
('product_id', '=', product_2.id),
('state', '=', 'confirmed')
])
self.assertEqual(len(mo2), 1, 'Second manufacture order was not created')
self.assertEqual(mo2.product_qty, 20, "Quantity to produce should be MO's 'to consume' qty + reordering rule max qty")
mo2_form = Form(mo2)
mo2_form.qty_producing = 20
mo2 = mo2_form.save()
mo2.button_mark_done()
self.assertEqual(mo.move_raw_ids.reserved_availability, 15, "Components should have been auto-reserved")
# add new component to 1st MO
mo_form = Form(mo)
with mo_form.move_raw_ids.new() as line:
line.product_id = product_4
line.product_uom_qty = 1
mo_form.save() # should trigger orderpoint to create and confirm 3rd MO
mo3 = self.env['mrp.production'].search([
('product_id', '=', product_4.id),
('state', '=', 'confirmed')
])
self.assertEqual(len(mo3), 1, 'Third manufacture order for added component was not created')
self.assertEqual(mo3.product_qty, 6, "Quantity to produce should be 1 + reordering rule max qty")
mo_form = Form(mo)
mo.move_raw_ids.quantity_done = 15
mo_form.qty_producing = 15
mo = mo_form.save()
mo.button_mark_done()
self.assertEqual(pick_output.move_ids_without_package.reserved_availability, 10, "Completed products should have been auto-reserved in picking")
@@ -224,9 +224,8 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon):
def test_flow_4(self):
""" Tick "Manufacture" and "MTO" on the components and trigger the
creation of the subcontracting manufacturing order through a receipt
picking. Checks that the delivery to the subcontractor is not created
at the receipt creation. Then run the scheduler and check that
the delivery and MO exist.
picking. Checks that the delivery and MO for its components are
automatically created.
"""
# Tick "manufacture" and MTO on self.comp2
mto_route = self.env.ref('stock.route_warehouse0_mto')
@@ -264,15 +263,6 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon):
picking_delivery = self.env['stock.picking'].search([('origin', 'ilike', '%' + picking_receipt.name + '%')])
self.assertFalse(picking_delivery)
move = self.env['stock.move'].search([
('product_id', '=', self.comp2.id),
('location_id', '=', warehouse.lot_stock_id.id),
('location_dest_id', '=', self.env.company.subcontracting_location_id.id)
])
self.assertFalse(move)
self.env['procurement.group'].run_scheduler(company_id=self.env.company.id)
move = self.env['stock.move'].search([
('product_id', '=', self.comp2.id),
('location_id', '=', warehouse.lot_stock_id.id),
+25
View File
@@ -13,6 +13,7 @@ from dateutil import relativedelta
from odoo import SUPERUSER_ID, _, api, fields, models
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.tools.float_utils import float_compare, float_is_zero, float_repr, float_round
from odoo.tools.misc import format_date
@@ -1730,3 +1731,27 @@ class StockMove(models.Model):
def _show_details_in_draft(self):
self.ensure_one()
return self.state != 'draft' or (self.picking_id.immediate_transfer and self.state == 'draft')
def _trigger_scheduler(self):
""" Check for auto-triggered orderpoints and trigger them. """
orderpoints_by_company = defaultdict(lambda: self.env['stock.warehouse.orderpoint'])
for move in self:
orderpoint = self.env['stock.warehouse.orderpoint'].search([
('product_id', '=', move.product_id.id),
('trigger', '=', 'auto'),
('location_id', 'parent_of', move.location_id.id),
('company_id', '=', move.company_id.id)
], limit=1)
if orderpoint:
orderpoints_by_company[orderpoint.company_id] |= orderpoint
for company, orderpoints in orderpoints_by_company.items():
orderpoints._procure_orderpoint_confirm(company_id=company, raise_user_error=False)
def _trigger_assign(self):
""" Check for and trigger action_assign for confirmed/partially_available moves related to done moves. """
domains = []
for move in self:
domains.append([('product_id', '=', move.product_id.id), ('location_id', '=', move.location_dest_id.id)])
static_domain = [('state', 'in', ['confirmed', 'partially_available']), ('procure_method', '=', 'make_to_stock')]
moves_to_reserve = self.env['stock.move'].search(expression.AND([static_domain, expression.OR(domains)]))
moves_to_reserve._action_assign()
+4 -19
View File
@@ -694,6 +694,9 @@ class Picking(models.Model):
self.mapped('move_lines')\
.filtered(lambda move: move.state == 'draft')\
._action_confirm()
# run scheduler for moves forecasted to not have enough in stock
self.mapped('move_lines').filtered(lambda move: move.state not in ('draft', 'cancel', 'done'))._trigger_scheduler()
return True
def action_assign(self):
@@ -713,19 +716,6 @@ class Picking(models.Model):
moves._action_assign()
package_level_done.write({'is_done': True})
# run scheduler for moves are not fully reserved
orderpoints_by_company = defaultdict(lambda: self.env['stock.warehouse.orderpoint'])
for move in moves:
orderpoint = self.env['stock.warehouse.orderpoint'].search([
('product_id', '=', move.product_id.id),
('trigger', '=', 'auto'),
('location_id', 'parent_of', move.location_id.id),
], limit=1)
if orderpoint:
orderpoints_by_company[move.company_id] |= orderpoint
for company, orderpoints in orderpoints_by_company.items():
orderpoints._procure_orderpoint_confirm(company_id=company, raise_user_error=False)
return True
def action_cancel(self):
@@ -777,12 +767,7 @@ class Picking(models.Model):
# if incoming moves make other confirmed/partially_available moves available, assign them
done_incoming_moves = self.filtered(lambda p: p.picking_type_id.code == 'incoming').move_lines.filtered(lambda m: m.state == 'done')
domains = []
for move in done_incoming_moves:
domains.append([('product_id', '=', move.product_id.id), ('location_id', '=', move.location_dest_id.id)])
static_domain = [('state', 'in', ['confirmed', 'partially_available']), ('procure_method', '=', 'make_to_stock')]
moves_to_reserve = self.env['stock.move'].search(expression.AND([static_domain, expression.OR(domains)]))
moves_to_reserve._action_assign()
done_incoming_moves._trigger_assign()
self._send_confirmation_email()
return True
+37 -2
View File
@@ -149,14 +149,20 @@ class TestProcRule(TransactionCase):
self.assertEqual(receipt_move.product_uom_qty, 17.0)
def test_reordering_rule_2(self):
"""Test when not enough product to assign picking, automatically run
reordering rule.
"""Test when there is not enough product to assign a picking => automatically run
reordering rule (RR). Add extra product to already confirmed picking => automatically
run another RR
"""
self.productA = self.env['product.product'].create({
'name': 'Desk Combination',
'type': 'product',
})
self.productB = self.env['product.product'].create({
'name': 'Desk Decoration',
'type': 'product',
})
warehouse = self.env['stock.warehouse'].search([], limit=1)
orderpoint_form = Form(self.env['stock.warehouse.orderpoint'])
orderpoint_form.product_id = self.productA
@@ -165,6 +171,14 @@ class TestProcRule(TransactionCase):
orderpoint_form.product_max_qty = 5.0
orderpoint = orderpoint_form.save()
self.env['stock.warehouse.orderpoint'].create({
'name': 'ProductB RR',
'location_id': warehouse.lot_stock_id.id,
'product_id': self.productB.id,
'product_min_qty': 0,
'product_max_qty': 5,
})
self.env['stock.rule'].create({
'name': 'Rule Supplier',
'route_id': warehouse.reception_route_id.id,
@@ -197,10 +211,31 @@ class TestProcRule(TransactionCase):
('product_id', '=', self.productA.id),
('location_id', '=', self.env.ref('stock.stock_location_suppliers').id)
])
self.assertTrue(receipt_move)
self.assertEqual(receipt_move.date.date(), date.today())
self.assertEqual(receipt_move.product_uom_qty, 17.0)
delivery_picking.write({'move_lines': [(0, 0, {
'name': 'Extra Move',
'product_id': self.productB.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 5.0,
'location_id': warehouse.lot_stock_id.id,
'location_dest_id': self.ref('stock.stock_location_customers'),
'picking_id': delivery_picking.id,
'additional': True
})]})
receipt_move2 = self.env['stock.move'].search([
('product_id', '=', self.productB.id),
('location_id', '=', self.env.ref('stock.stock_location_suppliers').id)
])
self.assertTrue(receipt_move2)
self.assertEqual(receipt_move2.date.date(), date.today())
self.assertEqual(receipt_move2.product_uom_qty, 10.0)
class TestProcRuleLoad(TransactionCase):
def setUp(cls):