mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 15:29:30 +00:00
fix(manufacturing): share transfer stock across Production Plan rows (v16) (#58834)
This commit is contained in:
@@ -1665,9 +1665,10 @@ def get_warehouse_list(warehouses):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_data=None):
|
def get_items_for_material_requests(
|
||||||
if isinstance(doc, str):
|
doc: str | dict, warehouses: str | list[dict] | None = None, get_parent_warehouse_data: bool | None = None
|
||||||
doc = frappe._dict(json.loads(doc))
|
):
|
||||||
|
doc = frappe._dict(json.loads(doc) if isinstance(doc, str) else doc)
|
||||||
|
|
||||||
if warehouses:
|
if warehouses:
|
||||||
warehouses = list(set(get_warehouse_list(warehouses)))
|
warehouses = list(set(get_warehouse_list(warehouses)))
|
||||||
@@ -1843,6 +1844,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
|||||||
|
|
||||||
if (ignore_existing_ordered_qty or get_parent_warehouse_data) and warehouses:
|
if (ignore_existing_ordered_qty or get_parent_warehouse_data) and warehouses:
|
||||||
new_mr_items = []
|
new_mr_items = []
|
||||||
|
locations_by_item = _get_transfer_locations(mr_items, warehouses, company)
|
||||||
for item in mr_items:
|
for item in mr_items:
|
||||||
get_materials_from_other_locations(
|
get_materials_from_other_locations(
|
||||||
item,
|
item,
|
||||||
@@ -1850,6 +1852,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
|||||||
new_mr_items,
|
new_mr_items,
|
||||||
company,
|
company,
|
||||||
consider_minimum_order_qty=doc.get("consider_minimum_order_qty"),
|
consider_minimum_order_qty=doc.get("consider_minimum_order_qty"),
|
||||||
|
locations=locations_by_item[item.get("item_code")],
|
||||||
)
|
)
|
||||||
|
|
||||||
mr_items = new_mr_items
|
mr_items = new_mr_items
|
||||||
@@ -1873,45 +1876,19 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
|||||||
|
|
||||||
|
|
||||||
def get_materials_from_other_locations(
|
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
|
|
||||||
|
|
||||||
purchase_uom = frappe.db.get_value("Item", item.get("item_code"), "purchase_uom")
|
purchase_uom = frappe.db.get_value("Item", item.get("item_code"), "purchase_uom")
|
||||||
|
|
||||||
locations = get_available_item_locations(
|
if locations is None:
|
||||||
item.get("item_code"),
|
locations = _get_transfer_locations([item], warehouses, company)[item.get("item_code")]
|
||||||
warehouses,
|
|
||||||
item.get("quantity") * item.get("conversion_factor"),
|
|
||||||
company,
|
|
||||||
ignore_validation=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
required_qty = item.get("quantity")
|
required_qty = item.get("quantity")
|
||||||
if item.get("conversion_factor") and item.get("purchase_uom") != item.get("stock_uom"):
|
if item.get("conversion_factor") and item.get("purchase_uom") != item.get("stock_uom"):
|
||||||
# Convert qty to stock UOM
|
# Convert qty to stock UOM
|
||||||
required_qty = required_qty * item.get("conversion_factor")
|
required_qty = required_qty * item.get("conversion_factor")
|
||||||
|
|
||||||
# get available material by transferring to production warehouse
|
required_qty = _transfer_from_locations(item, locations, new_mr_items, required_qty)
|
||||||
for d in locations:
|
|
||||||
if required_qty <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
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"),
|
|
||||||
"conversion_factor": 1.0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
required_qty -= quantity
|
|
||||||
new_mr_items.append(new_dict)
|
|
||||||
|
|
||||||
# raise purchase request for remaining qty
|
# raise purchase request for remaining qty
|
||||||
|
|
||||||
@@ -1931,6 +1908,59 @@ def get_materials_from_other_locations(
|
|||||||
new_mr_items.append(item)
|
new_mr_items.append(item)
|
||||||
|
|
||||||
|
|
||||||
|
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 _transfer_from_locations(item, locations, new_mr_items, required_qty):
|
||||||
|
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||||
|
transfers_by_warehouse = {}
|
||||||
|
for d in locations:
|
||||||
|
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)
|
||||||
|
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": warehouse,
|
||||||
|
"conversion_factor": 1.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
transfers_by_warehouse[warehouse] = new_dict
|
||||||
|
new_mr_items.append(new_dict)
|
||||||
|
return required_qty
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_item_data(item_code):
|
def get_item_data(item_code):
|
||||||
item_details = get_item_details(item_code)
|
item_details = get_item_details(item_code)
|
||||||
|
|||||||
@@ -1790,6 +1790,125 @@ class TestProductionPlan(ERPNextTestSuite):
|
|||||||
|
|
||||||
self.assertFalse(items)
|
self.assertFalse(items)
|
||||||
|
|
||||||
|
def _plan_for_transfer_allocation(self, rm_item, qty_per_order):
|
||||||
|
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||||
|
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
|
||||||
|
|
||||||
|
pln = create_production_plan(
|
||||||
|
item_code=fg_item,
|
||||||
|
ignore_existing_ordered_qty=1,
|
||||||
|
do_not_save=1,
|
||||||
|
skip_getting_mr_items=1,
|
||||||
|
)
|
||||||
|
pln.get_items_from = "Sales Order"
|
||||||
|
for _ in range(2):
|
||||||
|
so = make_sales_order(item_code=fg_item, qty=qty_per_order)
|
||||||
|
pln.append(
|
||||||
|
"sales_orders",
|
||||||
|
{
|
||||||
|
"sales_order": so.name,
|
||||||
|
"sales_order_date": so.transaction_date,
|
||||||
|
"customer": so.customer,
|
||||||
|
"grand_total": so.grand_total,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
pln.get_items()
|
||||||
|
return pln
|
||||||
|
|
||||||
|
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}).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_for_transfer_allocation(rm_item, qty_per_order=250)
|
||||||
|
pln.po_items[1].planned_qty = 1000
|
||||||
|
pln.for_warehouse = "_Test Warehouse - _TC"
|
||||||
|
warehouses = [{"warehouse": source_warehouse}]
|
||||||
|
|
||||||
|
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"]
|
||||||
|
)
|
||||||
|
self.assertEqual([row["quantity"] for row in items], [117, 133, 1000])
|
||||||
|
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_for_transfer_allocation(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_for_transfer_allocation(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.production_plan 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_transfer_and_purchase_mrp_for_purchase_uom(self):
|
def test_transfer_and_purchase_mrp_for_purchase_uom(self):
|
||||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||||
|
|||||||
Reference in New Issue
Block a user