mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 23:39:28 +00:00
fix(manufacturing): share transfer stock across Production Plan rows (#58812)
* fix(manufacturing): share transfer stock across Production Plan rows Fetch available stock once per item for the total demand and consume it across rows. Combine batch splits into one transfer row per source warehouse and demand. Round transfer quantities to the plan item precision so the shared balance never leaves a float residue as an extra row. * test(manufacturing): cover shared transfer stock across Production Plan rows
This commit is contained in:
@@ -456,6 +456,7 @@ def _apply_other_locations(doc, mr_items, warehouses, ignore_ordered_qty, get_pa
|
||||
return mr_items
|
||||
|
||||
new_mr_items = []
|
||||
locations_by_item = _get_transfer_locations(mr_items, warehouses, doc.get("company"))
|
||||
for item in mr_items:
|
||||
get_materials_from_other_locations(
|
||||
item,
|
||||
@@ -463,10 +464,30 @@ def _apply_other_locations(doc, mr_items, warehouses, ignore_ordered_qty, get_pa
|
||||
new_mr_items,
|
||||
doc.get("company"),
|
||||
consider_minimum_order_qty=doc.get("consider_minimum_order_qty"),
|
||||
locations=locations_by_item[item.get("item_code")],
|
||||
)
|
||||
return new_mr_items
|
||||
|
||||
|
||||
def _get_transfer_locations(mr_items, warehouses, company):
|
||||
from erpnext.stock.doctype.pick_list.pick_list import get_available_item_locations
|
||||
|
||||
required_qty_by_item = defaultdict(float)
|
||||
for item in mr_items:
|
||||
required_qty_by_item[item.get("item_code")] += max(
|
||||
0, flt(item.get("quantity")) * flt(item.get("conversion_factor"))
|
||||
)
|
||||
|
||||
return {
|
||||
item_code: get_available_item_locations(
|
||||
item_code, warehouses, required_qty, company, ignore_validation=True
|
||||
)
|
||||
if required_qty > 0
|
||||
else []
|
||||
for item_code, required_qty in required_qty_by_item.items()
|
||||
}
|
||||
|
||||
|
||||
def _set_default_suppliers(mr_items, company):
|
||||
procurement_types = ("Purchase", "Subcontracting")
|
||||
items = {
|
||||
@@ -731,17 +752,10 @@ def _material_request_item_row(
|
||||
|
||||
|
||||
def get_materials_from_other_locations(
|
||||
item, warehouses, new_mr_items, company, consider_minimum_order_qty=False
|
||||
item, warehouses, new_mr_items, company, consider_minimum_order_qty=False, locations=None
|
||||
):
|
||||
from erpnext.stock.doctype.pick_list.pick_list import get_available_item_locations
|
||||
|
||||
locations = get_available_item_locations(
|
||||
item.get("item_code"),
|
||||
warehouses,
|
||||
item.get("quantity") * item.get("conversion_factor"),
|
||||
company,
|
||||
ignore_validation=True,
|
||||
)
|
||||
if locations is None:
|
||||
locations = _get_transfer_locations([item], warehouses, company)[item.get("item_code")]
|
||||
|
||||
required_qty = item.get("quantity")
|
||||
if item.get("conversion_factor") and item.get("purchase_uom") != item.get("stock_uom"):
|
||||
@@ -753,23 +767,35 @@ def get_materials_from_other_locations(
|
||||
|
||||
|
||||
def _transfer_from_locations(item, locations, new_mr_items, required_qty):
|
||||
# get available material by transferring to production warehouse
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
transfers_by_warehouse = {}
|
||||
for d in locations:
|
||||
if required_qty <= 0:
|
||||
if flt(required_qty, precision) <= 0:
|
||||
return required_qty
|
||||
|
||||
quantity = flt(min(required_qty, d.get("qty")), precision)
|
||||
if quantity <= 0:
|
||||
continue
|
||||
d["qty"] -= quantity
|
||||
required_qty -= quantity
|
||||
|
||||
warehouse = d.get("warehouse")
|
||||
if warehouse in transfers_by_warehouse:
|
||||
transfer = transfers_by_warehouse[warehouse]
|
||||
transfer["quantity"] = flt(transfer["quantity"] + quantity, precision)
|
||||
continue
|
||||
|
||||
new_dict = copy.deepcopy(item)
|
||||
quantity = required_qty if d.get("qty") > required_qty else d.get("qty")
|
||||
new_dict.update(
|
||||
{
|
||||
"quantity": quantity,
|
||||
"material_request_type": "Material Transfer",
|
||||
"uom": new_dict.get("stock_uom"), # internal transfer should be in stock UOM
|
||||
"from_warehouse": d.get("warehouse"),
|
||||
"from_warehouse": warehouse,
|
||||
"conversion_factor": 1.0,
|
||||
}
|
||||
)
|
||||
required_qty -= quantity
|
||||
transfers_by_warehouse[warehouse] = new_dict
|
||||
new_mr_items.append(new_dict)
|
||||
return required_qty
|
||||
|
||||
|
||||
@@ -2248,6 +2248,117 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
self.assertEqual(row.warehouse, mrp_warhouse)
|
||||
self.assertEqual(row.quantity, 12.0)
|
||||
|
||||
def test_transfer_batches_share_stock_across_requirements(self):
|
||||
rm_item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"safety_stock": 100,
|
||||
"min_order_qty": 1234,
|
||||
}
|
||||
).name
|
||||
source_warehouse = "_Test Warehouse 1 - _TC"
|
||||
for qty in (1, 1, 5, 3, 3, 4, 100):
|
||||
make_stock_entry(item_code=rm_item, qty=qty, rate=100, target=source_warehouse)
|
||||
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=250)
|
||||
pln.po_items[1].planned_qty = 1000
|
||||
pln.for_warehouse = "_Test Warehouse - _TC"
|
||||
warehouses = [{"warehouse": source_warehouse}]
|
||||
|
||||
for safety_stock in (0, 1):
|
||||
for minimum_order_qty in (0, 1):
|
||||
with self.subTest(safety_stock=safety_stock, minimum_order_qty=minimum_order_qty):
|
||||
pln.include_safety_stock = safety_stock
|
||||
pln.consider_minimum_order_qty = minimum_order_qty
|
||||
items = get_items_for_material_requests(pln.as_dict(), warehouses=warehouses)
|
||||
self.assertEqual(
|
||||
[row["material_request_type"] for row in items],
|
||||
["Material Transfer", "Purchase", "Purchase"],
|
||||
)
|
||||
purchase_qty = [1234, 0] if minimum_order_qty else [133 + safety_stock * 100, 1000]
|
||||
self.assertEqual([row["quantity"] for row in items], [117, *purchase_qty])
|
||||
self.assertEqual(items[0]["from_warehouse"], source_warehouse)
|
||||
self.assertEqual([row["warehouse"] for row in items], [pln.for_warehouse] * 3)
|
||||
self.assertEqual([row["required_bom_qty"] for row in items], [250, 250, 1000])
|
||||
self.assertEqual(
|
||||
[row["sales_order"] for row in items],
|
||||
[pln.po_items[0].sales_order] * 2 + [pln.po_items[1].sales_order],
|
||||
)
|
||||
self.assertEqual(
|
||||
items, get_items_for_material_requests(pln.as_dict(), warehouses=warehouses)
|
||||
)
|
||||
|
||||
def test_transfer_batches_keep_source_warehouses_and_requirements_separate(self):
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "has_batch_no": 1, "create_new_batch": 1}).name
|
||||
source_warehouses = ["_Test Warehouse 1 - _TC", "_Test Warehouse 2 - _TC"]
|
||||
for warehouse, quantities in zip(source_warehouses, ((10, 20), (50, 60)), strict=True):
|
||||
for qty in quantities:
|
||||
make_stock_entry(item_code=rm_item, qty=qty, rate=100, target=warehouse)
|
||||
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=50)
|
||||
pln.po_items[1].planned_qty = 100
|
||||
pln.for_warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
items = get_items_for_material_requests(
|
||||
pln.as_dict(), warehouses=[{"warehouse": warehouse} for warehouse in source_warehouses]
|
||||
)
|
||||
transfers = [row for row in items if row["material_request_type"] == "Material Transfer"]
|
||||
self.assertEqual(len(transfers), 3)
|
||||
self.assertEqual(
|
||||
{(row["sales_order"], row["from_warehouse"]): row["quantity"] for row in transfers},
|
||||
{
|
||||
(pln.po_items[0].sales_order, source_warehouses[0]): 30,
|
||||
(pln.po_items[0].sales_order, source_warehouses[1]): 20,
|
||||
(pln.po_items[1].sales_order, source_warehouses[1]): 90,
|
||||
},
|
||||
)
|
||||
purchases = [row for row in items if row["material_request_type"] == "Purchase"]
|
||||
self.assertEqual(len(purchases), 1)
|
||||
self.assertEqual(purchases[0]["quantity"], 10)
|
||||
self.assertEqual(purchases[0]["sales_order"], pln.po_items[1].sales_order)
|
||||
|
||||
def test_transfer_shared_stock_uses_stock_uom(self):
|
||||
rm_item = make_item(
|
||||
properties={"is_stock_item": 1, "stock_uom": "Nos", "purchase_uom": "_Test UOM 1"},
|
||||
uoms=[{"uom": "_Test UOM 1", "conversion_factor": 10}],
|
||||
).name
|
||||
source_warehouse = "_Test Warehouse 1 - _TC"
|
||||
make_stock_entry(item_code=rm_item, qty=60, rate=100, target=source_warehouse)
|
||||
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=50)
|
||||
pln.for_warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
items = get_items_for_material_requests(pln.as_dict(), warehouses=[{"warehouse": source_warehouse}])
|
||||
self.assertEqual(
|
||||
[row["material_request_type"] for row in items],
|
||||
["Material Transfer", "Material Transfer", "Purchase"],
|
||||
)
|
||||
self.assertEqual([row["quantity"] for row in items], [50, 10, 4])
|
||||
self.assertEqual([row["uom"] for row in items], ["Nos", "Nos", "_Test UOM 1"])
|
||||
self.assertEqual([row["conversion_factor"] for row in items], [1, 1, 10])
|
||||
self.assertEqual(
|
||||
[row["sales_order"] for row in items],
|
||||
[pln.po_items[0].sales_order] + [pln.po_items[1].sales_order] * 2,
|
||||
)
|
||||
|
||||
def test_transfer_shared_stock_rounds_away_float_residue(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.services.material_request import (
|
||||
_transfer_from_locations,
|
||||
)
|
||||
|
||||
locations = [
|
||||
frappe._dict(qty=0.7, warehouse="_Test Warehouse 1 - _TC"),
|
||||
frappe._dict(qty=1, warehouse="_Test Warehouse 2 - _TC"),
|
||||
]
|
||||
transfers = []
|
||||
for quantity in (0.1, 0.2, 0.4):
|
||||
item = {"item_code": "Raw Material Item 1", "quantity": quantity, "conversion_factor": 1}
|
||||
self.assertEqual(_transfer_from_locations(item, locations, transfers, quantity), 0)
|
||||
|
||||
self.assertEqual(
|
||||
[(row["from_warehouse"], row["quantity"]) for row in transfers],
|
||||
[("_Test Warehouse 1 - _TC", quantity) for quantity in (0.1, 0.2, 0.4)],
|
||||
)
|
||||
|
||||
def test_purchase_uom_falls_back_to_uom_conversion_factor(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
|
||||
Reference in New Issue
Block a user