Files
odoo_source/addons/mrp/tests/test_byproduct.py
T
Adrien Widart (awt) 09f71c0809 [FIX] stock,mrp: redirect byproducts with capacity constraints
When redirecting the SML of byproducts, the capacity constraints of
the locations are not considered.

To reproduce the issue:
1. In Settings, enable:
   - By-Products
   - Storage Locations
   - Storage Categories
2. Create three storable products P01, P02, P_comp
3. Create a storage category SG:
   - Max Weight: 1000
   - Capacity by Products:
     - 2 x P02
4. Create two locations L01, L02:
   - Parent Location: WH/Stock
   - Storage Category: SG
5. Create a putaway rule:
   - From: WH/Stock
   - Product: P02
   - To: WH/Stock
   - Having Category: SG
6. Create a BoM:
   - Product: P01
   - Components: 1 x P_compo
   - By-products: 1 x P02
7. Update on hand qty of P02:
   - 1 x P02 at L01
8. Create and confirm a MO with 2 x P01
9. Set the producing qty to 2
10. Open the detailed operations of P02

Error: The destination location is L01, this would break the
capacity constraint. The location should be L02

When setting the producing qty, we also set the done quantities of
the finished moves and we redirect them thanks to the putaway rules:
https://github.com/odoo/odoo/blob/cc6e31efe6fdd9dee16de1565afe9e1b502de45d/addons/stock/models/stock_move.py#L1885-L1888
However, the SML created only has the `qty_done` defined and, when
applying the putaway rules, we consider the `product_uom_qty` which
is equal to 0 in such a case.

So, when trying to redirect the SML of P02, we check if the capacity
constraint of L01 would be exceeded with the quantity of the SML.
But, as explained above, that quantity will be 0, so we conclude
that the capacity constraint is ok and we return L01 as best solution.

This is the reason why this commit suggests the use of the maximum
between done quantity and reserved quantity. This logic is already
used in the putaway application process, when we check the forecasted
quantity by location:
https://github.com/odoo/odoo/blob/a217ba27600c64c9104f8c1f003afd74ad06bd05/addons/stock/models/stock_location.py#L297-L301

OPW-3100322

closes odoo/odoo#109669

X-original-commit: 4ee8f974f993d2bbea7d9fc00779ebb1c1fa047d
Signed-off-by: William Henrotin (whe) <whe@odoo.com>
Signed-off-by: Adrien Widart <awt@odoo.com>
2023-01-12 08:02:48 +01:00

331 lines
16 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import Form
from odoo.tests import common
class TestMrpByProduct(common.TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.MrpBom = cls.env['mrp.bom']
cls.warehouse = cls.env.ref('stock.warehouse0')
route_manufacture = cls.warehouse.manufacture_pull_id.route_id.id
route_mto = cls.warehouse.mto_pull_id.route_id.id
cls.uom_unit_id = cls.env.ref('uom.product_uom_unit').id
def create_product(name, route_ids=[]):
return cls.env['product.product'].create({
'name': name,
'type': 'product',
'route_ids': route_ids})
# Create product A, B, C.
# --------------------------
cls.product_a = create_product('Product A', route_ids=[(6, 0, [route_manufacture, route_mto])])
cls.product_b = create_product('Product B', route_ids=[(6, 0, [route_manufacture, route_mto])])
cls.product_c_id = create_product('Product C', route_ids=[]).id
cls.bom_byproduct = cls.MrpBom.create({
'product_tmpl_id': cls.product_a.product_tmpl_id.id,
'product_qty': 1.0,
'type': 'normal',
'product_uom_id': cls.uom_unit_id,
'bom_line_ids': [(0, 0, {'product_id': cls.product_c_id, 'product_uom_id': cls.uom_unit_id, 'product_qty': 2})],
'byproduct_ids': [(0, 0, {'product_id': cls.product_b.id, 'product_uom_id': cls.uom_unit_id, 'product_qty': 1})]
})
def test_00_mrp_byproduct(self):
""" Test by product with production order."""
# Create BOM for product B
# ------------------------
bom_product_b = self.MrpBom.create({
'product_tmpl_id': self.product_b.product_tmpl_id.id,
'product_qty': 1.0,
'type': 'normal',
'product_uom_id': self.uom_unit_id,
'bom_line_ids': [(0, 0, {'product_id': self.product_c_id, 'product_uom_id': self.uom_unit_id, 'product_qty': 2})]
})
# Create production order for product A
# -------------------------------------
mnf_product_a_form = Form(self.env['mrp.production'])
mnf_product_a_form.product_id = self.product_a
mnf_product_a_form.bom_id = self.bom_byproduct
mnf_product_a_form.product_qty = 2.0
mnf_product_a = mnf_product_a_form.save()
mnf_product_a.action_confirm()
# I confirm the production order.
self.assertEqual(mnf_product_a.state, 'confirmed', 'Production order should be in state confirmed')
# Now I check the stock moves for the byproduct I created in the bill of material.
# This move is created automatically when I confirmed the production order.
moves = mnf_product_a.move_raw_ids | mnf_product_a.move_finished_ids
self.assertTrue(moves, 'No moves are created !')
# I consume and produce the production of products.
# I create record for selecting mode and quantity of products to produce.
mo_form = Form(mnf_product_a)
mo_form.qty_producing = 2.00
mnf_product_a = mo_form.save()
# I finish the production order.
self.assertEqual(len(mnf_product_a.move_raw_ids), 1, "Wrong consume move on production order.")
consume_move_c = mnf_product_a.move_raw_ids
by_product_move = mnf_product_a.move_finished_ids.filtered(lambda x: x.product_id.id == self.product_b.id)
# Check sub production produced quantity...
self.assertEqual(consume_move_c.product_uom_qty, 4, "Wrong consumed quantity of product c.")
self.assertEqual(by_product_move.product_uom_qty, 2, "Wrong produced quantity of sub product.")
mnf_product_a._post_inventory()
# I see that stock moves of External Hard Disk including Headset USB are done now.
self.assertFalse(any(move.state != 'done' for move in moves), 'Moves are not done!')
def test_01_mrp_byproduct(self):
self.env["stock.quant"].create({
"product_id": self.product_c_id,
"location_id": self.warehouse.lot_stock_id.id,
"quantity": 4,
})
bom_product_a = self.MrpBom.create({
'product_tmpl_id': self.product_a.product_tmpl_id.id,
'product_qty': 1.0,
'type': 'normal',
'product_uom_id': self.uom_unit_id,
'bom_line_ids': [(0, 0, {'product_id': self.product_c_id, 'product_uom_id': self.uom_unit_id, 'product_qty': 2})]
})
mnf_product_a_form = Form(self.env['mrp.production'])
mnf_product_a_form.product_id = self.product_a
mnf_product_a_form.bom_id = bom_product_a
mnf_product_a_form.product_qty = 2.0
mnf_product_a = mnf_product_a_form.save()
mnf_product_a.action_confirm()
self.assertEqual(mnf_product_a.state, "confirmed")
mnf_product_a.move_raw_ids._action_assign()
mnf_product_a.move_raw_ids.quantity_done = mnf_product_a.move_raw_ids.product_uom_qty
mnf_product_a.move_raw_ids._action_done()
self.assertEqual(mnf_product_a.state, "progress")
mnf_product_a.qty_producing = 2
mnf_product_a.button_mark_done()
self.assertTrue(mnf_product_a.move_finished_ids)
self.assertEqual(mnf_product_a.state, "done")
def test_change_product(self):
""" Create a production order for a specific product with a BoM. Then change the BoM and the finished product for
other ones and check the finished product of the first mo did not became a byproduct of the second one."""
# Create BOM for product A with product B as component
bom_product_a = self.MrpBom.create({
'product_tmpl_id': self.product_a.product_tmpl_id.id,
'product_qty': 1.0,
'type': 'normal',
'product_uom_id': self.uom_unit_id,
'bom_line_ids': [(0, 0, {'product_id': self.product_b.id, 'product_uom_id': self.uom_unit_id, 'product_qty': 2})],
})
bom_product_a_2 = self.MrpBom.create({
'product_tmpl_id': self.product_b.product_tmpl_id.id,
'product_qty': 1.0,
'type': 'normal',
'product_uom_id': self.uom_unit_id,
'bom_line_ids': [(0, 0, {'product_id': self.product_c_id, 'product_uom_id': self.uom_unit_id, 'product_qty': 2})],
})
# Create production order for product A
# -------------------------------------
mnf_product_a_form = Form(self.env['mrp.production'])
mnf_product_a_form.product_id = self.product_a
mnf_product_a_form.bom_id = bom_product_a
mnf_product_a_form.product_qty = 1.0
mnf_product_a = mnf_product_a_form.save()
mnf_product_a_form = Form(mnf_product_a)
mnf_product_a_form.bom_id = bom_product_a_2
mnf_product_a = mnf_product_a_form.save()
self.assertEqual(mnf_product_a.move_raw_ids.product_id.id, self.product_c_id)
self.assertFalse(mnf_product_a.move_byproduct_ids)
def test_default_uom(self):
""" Tests the `uom_id` on the byproduct gets set automatically while creating a byproduct with a product,
without the need to call an onchange or to set the uom manually in the create.
"""
# Set a specific UOM on the byproduct on purpose to make sure it's not just a default on the unit UOM
# that makes the test pass.
self.product_b.product_tmpl_id.uom_id = self.env.ref('uom.product_uom_dozen')
bom = self.MrpBom.create({
'product_tmpl_id': self.product_a.product_tmpl_id.id,
'product_qty': 1.0,
'type': 'normal',
'byproduct_ids': [(0, 0, {'product_id': self.product_b.id, 'product_qty': 1})]
})
self.assertEqual(bom.byproduct_ids.product_uom_id, self.env.ref('uom.product_uom_dozen'))
def test_finished_and_byproduct_moves(self):
"""
Tests the behavior of the `create` override in the model `mrp.production`
regarding the values for the fields `move_finished_ids` and `move_byproduct_ids`.
The behavior is a bit tricky, because the moves included in `move_byproduct_ids`
are included in the `move_finished_ids`. `move_byproduct_ids` is a subset of `move_finished_ids`.
So, when creating a manufacturing order, whether:
- Only `move_finished_ids` is passed, containing both the finished product and the by-products of the BOM,
- Only `move_byproduct_ids` is passed, only containing the by-products of the BOM,
- Both `move_finished_ids` and `move_byproduct_ids` are passed,
holding the product finished and the byproducts respectively
At the end, in the created manufacturing order
`move_finished_ids` must contain both the finished product, and the by-products,
`move_byproduct_ids` must contain only the by-products.
Besides, the code shouldn't raise an error
because only one of the two `move_finished_ids`, `move_byproduct_ids` is provided.
In addition, the test voluntary sets a different produced quantity
for the finished product and the by-products moves than defined in the BOM
as it's the point to manually pass the `move_finished_ids` and `move_byproduct_ids`
when creating a manufacturing order, set different values than the defaults, in this case
a different produced quantity than the defaults from the BOM.
"""
bom_product_a = self.MrpBom.create({
'product_tmpl_id': self.product_a.product_tmpl_id.id,
'product_qty': 1.0,
'type': 'normal',
'bom_line_ids': [(0, 0, {
'product_id': self.product_c_id, 'product_uom_id': self.uom_unit_id, 'product_qty': 2.0
})],
'byproduct_ids': [(0, 0, {
'product_id': self.product_b.id, 'product_uom_id': self.uom_unit_id, 'product_qty': 1.0
})]
})
for expected_finished_qty, expected_byproduct_qty, values in [
# Only `move_finished_ids` passed, containing both the finished product and the by-product
(3.0, 4.0, {
'move_finished_ids': [
(0, 0, {
'product_id': self.product_a.id,
'product_uom_qty': 3.0,
'location_id': self.product_a.property_stock_production,
'location_dest_id': self.warehouse.lot_stock_id.id,
}),
(0, 0, {
'product_id': self.product_b.id,
'product_uom_qty': 4.0,
'location_id': self.product_a.property_stock_production,
'location_dest_id': self.warehouse.lot_stock_id.id,
}),
],
}),
# Only `move_byproduct_ids` passed, containing the by-product move only
(2.0, 4.0, {
'move_byproduct_ids': [
(0, 0, {
'product_id': self.product_b.id,
'product_uom_qty': 4.0,
'location_id': self.product_a.property_stock_production,
'location_dest_id': self.warehouse.lot_stock_id.id,
}),
],
}),
# Both `move_finished_ids` and `move_byproduct_ids` passed,
# containing respectively the finished product and the by-product
(3.0, 4.0, {
'move_finished_ids': [
(0, 0, {
'product_id': self.product_a.id,
'product_uom_qty': 3.0,
'location_id': self.product_a.property_stock_production,
'location_dest_id': self.warehouse.lot_stock_id.id,
}),
],
'move_byproduct_ids': [
(0, 0, {
'product_id': self.product_b.id,
'product_uom_qty': 4.0,
'location_id': self.product_a.property_stock_production,
'location_dest_id': self.warehouse.lot_stock_id.id,
}),
],
}),
]:
mo = self.env['mrp.production'].create({
'product_id': self.product_a.id,
'bom_id': bom_product_a.id,
'product_qty': 2.0,
**values,
})
self.assertEqual(mo.move_finished_ids.product_id, self.product_a + self.product_b)
self.assertEqual(mo.move_byproduct_ids.product_id, self.product_b)
finished_move = mo.move_finished_ids.filtered(lambda x: x.product_id == self.product_a)
self.assertEqual(
finished_move.product_uom_qty, expected_finished_qty, "Wrong produced quantity of finished product."
)
by_product_move = mo.move_finished_ids.filtered(lambda x: x.product_id == self.product_b)
self.assertEqual(
by_product_move.product_uom_qty, expected_byproduct_qty, "Wrong produced quantity of by-product."
)
# Also check the produced quantity of the by-product through `move_byproduct_ids`
self.assertEqual(
mo.move_byproduct_ids.product_uom_qty, expected_byproduct_qty, "Wrong produced quantity of by-product."
)
def test_byproduct_putaway(self):
"""
Test the byproducts are dispatched correctly with putaway rules. We have
a byproduct P and two sublocations L01, L02 with a capacity constraint:
max 2 x P by location. There is already 1 x P at L01. Process a MO with
2 x P as byproducts. They should be redirected to L02
"""
self.stock_location = self.env.ref('stock.stock_location_stock')
stor_category = self.env['stock.storage.category'].create({
'name': 'Super Storage Category',
'max_weight': 1000,
'product_capacity_ids': [(0, 0, {
'product_id': self.product_b.id,
'quantity': 2,
})]
})
shelf1_location = self.env['stock.location'].create({
'name': 'shelf1',
'usage': 'internal',
'location_id': self.stock_location.id,
'storage_category_id': stor_category.id,
})
shelf2_location = self.env['stock.location'].create({
'name': 'shelf2',
'usage': 'internal',
'location_id': self.stock_location.id,
'storage_category_id': stor_category.id,
})
self.env['stock.putaway.rule'].create({
'product_id': self.product_b.id,
'location_in_id': self.stock_location.id,
'location_out_id': self.stock_location.id,
'storage_category_id': stor_category.id,
})
self.env['stock.putaway.rule'].create({
'product_id': self.product_a.id,
'location_in_id': self.stock_location.id,
'location_out_id': shelf2_location.id,
})
self.env['stock.quant']._update_available_quantity(self.product_b, shelf1_location, 1)
mo_form = Form(self.env['mrp.production'])
mo_form.product_id = self.product_a
mo_form.bom_id = self.bom_byproduct
mo_form.product_qty = 2.0
mo = mo_form.save()
mo.action_confirm()
mo_form = Form(mo)
mo_form.qty_producing = 2.00
mo = mo_form.save()
mo._post_inventory()
byproduct_move_line = mo.move_byproduct_ids.move_line_ids
finished_move_line = mo.move_finished_ids.filtered(lambda m: m.product_id == self.product_a).move_line_ids
self.assertEqual(byproduct_move_line.location_dest_id, shelf2_location)
self.assertEqual(finished_move_line.location_dest_id, shelf2_location)