[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:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user