Skip to content

Commit

Permalink
Merge pull request #44988 from frappe/35225
Browse files Browse the repository at this point in the history
feat: Validate sub assembly and material request items in Production …
  • Loading branch information
rohitwaghchaure authored Jan 9, 2025
2 parents 9033951 + 87f1f6e commit 1f1c01d
Show file tree
Hide file tree
Showing 10 changed files with 312 additions and 34 deletions.
20 changes: 20 additions & 0 deletions erpnext/buying/doctype/purchase_order/purchase_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,9 @@ def on_submit(self):
if self.is_against_so():
self.update_status_updater()

if self.is_against_pp():
self.update_status_updater_if_from_pp()

self.update_prevdoc_status()
if not self.is_subcontracted or self.is_old_subcontracting_flow:
self.update_requested_qty()
Expand Down Expand Up @@ -550,6 +553,20 @@ def update_status_updater(self):
}
)

def update_status_updater_if_from_pp(self):
self.status_updater.append(
{
"source_dt": "Purchase Order Item",
"target_dt": "Production Plan Sub Assembly Item",
"join_field": "production_plan_sub_assembly_item",
"target_field": "received_qty",
"target_parent_dt": "Production Plan",
"target_parent_field": "",
"target_ref_field": "qty",
"source_field": "fg_item_qty",
}
)

def update_delivered_qty_in_sales_order(self):
"""Update delivered qty in Sales Order for drop ship"""
sales_orders_to_update = []
Expand All @@ -570,6 +587,9 @@ def has_drop_ship_item(self):
def is_against_so(self):
return any(d.sales_order for d in self.items if d.sales_order)

def is_against_pp(self):
return any(d.production_plan for d in self.items if d.production_plan)

def set_received_qty_for_drop_ship_items(self):
for item in self.items:
if item.delivered_by_supplier == 1:
Expand Down
31 changes: 21 additions & 10 deletions erpnext/controllers/status_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,19 +205,19 @@ def get_status(self):
Get the status of the document.
Returns:
dict: A dictionary containing the status. This allows callers to receive
a dictionary for efficient bulk updates, for example when `per_billed`
and other status fields also need to be updated.
dict: A dictionary containing the status. This allows callers to receive
a dictionary for efficient bulk updates, for example when `per_billed`
and other status fields also need to be updated.
Note:
Can be overriden on a doctype to implement more localized status updater logic.
Can be overriden on a doctype to implement more localized status updater logic.
Example:
{
"status": "Draft",
"per_billed": 50,
"billing_status": "Partly Billed"
}
{
"status": "Draft",
"per_billed": 50,
"billing_status": "Partly Billed"
}
"""
if self.doctype not in status_map:
return {"status": self.status}
Expand Down Expand Up @@ -279,9 +279,20 @@ def validate_qty(self):
if d.doctype == args["source_dt"] and d.get(args["join_field"]):
args["name"] = d.get(args["join_field"])

is_from_pp = (
hasattr(d, "production_plan_sub_assembly_item")
and frappe.db.get_value(
"Production Plan Sub Assembly Item",
d.production_plan_sub_assembly_item,
"type_of_manufacturing",
)
== "Subcontract"
)
args["item_code"] = "production_item" if is_from_pp else "item_code"

# get all qty where qty > target_field
item = frappe.db.sql(
"""select item_code, `{target_ref_field}`,
"""select `{item_code}` as item_code, `{target_ref_field}`,
`{target_field}`, parenttype, parent from `tab{target_dt}`
where `{target_ref_field}` < `{target_field}`
and name=%s and docstatus=1""".format(**args),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "\nPurchase\nMaterial Transfer\nMaterial Issue\nManufacture\nCustomer Provided"
"options": "\nPurchase\nMaterial Transfer\nMaterial Issue\nManufacture\nSubcontracting\nCustomer Provided"
},
{
"fieldname": "column_break_4",
Expand Down Expand Up @@ -115,9 +115,12 @@
"read_only": 1
},
{
"allow_on_submit": 1,
"default": "0",
"fieldname": "requested_qty",
"fieldtype": "Float",
"label": "Requested Qty",
"no_copy": 1,
"read_only": 1
},
{
Expand Down Expand Up @@ -202,7 +205,7 @@
],
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:05.436575",
"modified": "2024-12-30 18:06:22.288340",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Material Request Plan Item",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ class MaterialRequestPlanItem(Document):
item_code: DF.Link
item_name: DF.Data | None
material_request_type: DF.Literal[
"", "Purchase", "Material Transfer", "Material Issue", "Manufacture", "Customer Provided"
"",
"Purchase",
"Material Transfer",
"Material Issue",
"Manufacture",
"Subcontracting",
"Customer Provided",
]
min_order_qty: DF.Float
ordered_qty: DF.Float
Expand Down
41 changes: 39 additions & 2 deletions erpnext/manufacturing/doctype/production_plan/production_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import copy
import json
from collections import defaultdict

import frappe
from frappe import _, msgprint
Expand Down Expand Up @@ -722,6 +723,9 @@ def make_work_order(self):
if not wo_list:
frappe.msgprint(_("No Work Orders were created"))

if not po_list:
frappe.msgprint(_("No Purchase Orders were created"))

def make_work_order_for_finished_goods(self, wo_list, default_warehouses):
items_data = self.get_production_items()

Expand Down Expand Up @@ -781,6 +785,21 @@ def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders):
if not subcontracted_po:
return

def calculate_sub_assembly_items():
items_to_remove = defaultdict(list)
for supplier, items in subcontracted_po.items():
for item in items:
if item.qty == item.received_qty:
items_to_remove[supplier].append(item)
elif item.received_qty:
item.qty -= item.received_qty

subcontracted_po[supplier] = [item for item in items if item not in items_to_remove[supplier]]

return {key: value for key, value in subcontracted_po.items() if value}

subcontracted_po = calculate_sub_assembly_items()

for supplier, po_list in subcontracted_po.items():
po = frappe.new_doc("Purchase Order")
po.company = self.company
Expand Down Expand Up @@ -847,13 +866,31 @@ def create_work_order(self, item):
except OverProductionError:
pass

def validate_mr_subcontracted(self):
for row in self.mr_items:
if row.material_request_type == "Subcontracting":
if not frappe.db.get_value("Item", row.item_code, "is_sub_contracted_item"):
frappe.throw(
_("Item {0} is not a subcontracted item").format(row.item_code),
title=_("Invalid Item"),
)

@frappe.whitelist()
def make_material_request(self):
self.validate_mr_subcontracted()

"""Create Material Requests grouped by Sales Order and Material Request Type"""
material_request_list = []
material_request_map = {}

if all([item.requested_qty == item.quantity for item in self.mr_items]):
msgprint(_("All items are already requested"))
return

for item in self.mr_items:
if item.quantity == item.requested_qty:
continue

item_doc = frappe.get_cached_doc("Item", item.item_code)

material_request_type = item.material_request_type or item_doc.default_material_request_type
Expand Down Expand Up @@ -887,7 +924,7 @@ def make_material_request(self):
"from_warehouse": item.from_warehouse
if material_request_type == "Material Transfer"
else None,
"qty": item.quantity,
"qty": item.quantity - item.requested_qty,
"schedule_date": schedule_date,
"warehouse": item.warehouse,
"sales_order": item.sales_order,
Expand Down Expand Up @@ -1047,7 +1084,7 @@ def all_items_completed(self):
filters={
"production_plan": self.name,
"status": ("not in", ["Closed", "Stopped"]),
"docstatus": ("<", 2),
"docstatus": 1,
},
fields="status",
pluck="status",
Expand Down
135 changes: 133 additions & 2 deletions erpnext/manufacturing/doctype/production_plan/test_production_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,11 +449,38 @@ def test_production_plan_subassembly_default_supplier(self):
self.assertEqual(plan.sub_assembly_items[0].supplier, "_Test Supplier")

def test_production_plan_for_subcontracting_po(self):
from erpnext.controllers.status_updater import OverAllowanceError
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.subcontracting.doctype.subcontracting_bom.test_subcontracting_bom import (
create_subcontracting_bom,
)

def make_purchase_receipt_from_po(po_doc):
from erpnext.buying.doctype.purchase_order.purchase_order import make_subcontracting_order
from erpnext.controllers.subcontracting_controller import make_rm_stock_entry
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
make_subcontracting_receipt,
)
from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import (
make_purchase_receipt as scr_make_purchase_receipt,
)

sco = make_subcontracting_order(po_doc.name)
sco.supplier_warehouse = "Work In Progress - _TC1"
sco.items[0].warehouse = "Finished Goods - _TC1"
sco.submit()
make_purchase_receipt(
qty=10,
item_code="Test Motherboard Wires 1",
company="_Test Company 1",
warehouse="Work In Progress - _TC1",
).submit()
make_rm_stock_entry(sco.name)
scr = make_subcontracting_receipt(sco.name)
scr.submit()
scr_make_purchase_receipt(scr.name).submit()

fg_item = "Test Motherboard 1"
bom_tree_1 = {"Test Laptop 1": {fg_item: {"Test Motherboard Wires 1": {}}}}
create_nested_bom(bom_tree_1, prefix="")
Expand All @@ -478,7 +505,12 @@ def test_production_plan_for_subcontracting_po(self):
)

plan = create_production_plan(
item_code="Test Laptop 1", planned_qty=10, use_multi_level_bom=1, do_not_submit=True
item_code="Test Laptop 1",
planned_qty=10,
use_multi_level_bom=1,
do_not_submit=True,
company="_Test Company 1",
skip_getting_mr_items=True,
)
plan.get_sub_assembly_items()
plan.set_default_supplier_for_subcontracting_order()
Expand All @@ -492,10 +524,109 @@ def test_production_plan_for_subcontracting_po(self):
self.assertEqual(po_doc.supplier, "_Test Supplier")
self.assertEqual(po_doc.items[0].qty, 10.0)
self.assertEqual(po_doc.items[0].fg_item_qty, 10.0)
self.assertEqual(po_doc.items[0].fg_item_qty, 10.0)
self.assertEqual(po_doc.items[0].fg_item, fg_item)
self.assertEqual(po_doc.items[0].item_code, service_item)

po_doc.items[0].qty = 11
po_doc.items[0].fg_item_qty = 11

# Test - 1 : Quantity of item cannot exceed quantity in production plan
self.assertRaises(OverAllowanceError, po_doc.submit)

po_doc.cancel()
po_doc = frappe.copy_doc(po_doc)
po_doc.items[0].qty = 5
po_doc.items[0].fg_item_qty = 5
po_doc.submit()
make_purchase_receipt_from_po(po_doc)

plan.reload()
plan.make_work_order()
po = frappe.db.get_value("Purchase Order Item", {"production_plan": plan.name}, "parent")
po_doc = frappe.get_doc("Purchase Order", po)

# Test - 2 : Quantity of item in new PO should be the available quantity from Production Plan
self.assertEqual(po_doc.items[0].qty, 5.0)

po_doc.submit()
plan.make_work_order()

# Test - 3 : New POs should not be created since the quantity is already fulfilled
self.assertEqual(
frappe.db.count("Purchase Order Item", {"production_plan": plan.name, "docstatus": 1}), 2
) # 2 since we have already created and submitted 2 POs

def test_production_plan_for_mr_items(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom

def setup_item(fg_item):
item_doc = frappe.get_doc("Item", fg_item)
company = "_Test Company"

item_doc.is_sub_contracted_item = 1
for row in item_doc.item_defaults:
if row.company == company and not row.default_supplier:
row.default_supplier = "_Test Supplier"

if not item_doc.item_defaults:
item_doc.append("item_defaults", {"company": company, "default_supplier": "_Test Supplier"})

item_doc.save()

fg_item = "Test Motherboard 1"
fg_item_2 = "Test CPU 1"
bom_tree_1 = {
"Test Laptop 1": {fg_item: {"Test Motherboard Wires 1": {}}, fg_item_2: {"Test Pins 1": {}}}
}
create_nested_bom(bom_tree_1, prefix="")

setup_item(fg_item)
setup_item(fg_item_2)

plan = create_production_plan(
item_code="Test Laptop 1", planned_qty=10, use_multi_level_bom=1, do_not_submit=True
)
plan.get_sub_assembly_items()
plan.set_default_supplier_for_subcontracting_order()
plan.submit()

plan.make_material_request()
mr_item = frappe.db.get_value("Material Request Item", {"production_plan": plan.name}, "parent")
mr_doc = frappe.get_doc("Material Request", mr_item)
mr_doc.submit()
plan.reload()
plan.make_material_request()

# Test 1 : No more MRs should be created as quantity from Production Plan is fulfilled
self.assertEqual(frappe.db.count("Material Request Item", {"production_plan": plan.name}), 2)

mr_doc.cancel()
plan.reload()

# Test 2 : Requested quantity should be updated in Production Plan on cancellation of MR
self.assertEqual(plan.mr_items[0].requested_qty, 0)

plan.make_material_request()
mr_item = frappe.db.get_value("Material Request Item", {"production_plan": plan.name}, "parent")
mr_doc = frappe.get_doc("Material Request", mr_item)
mr_doc.items[0].qty = 5
mr_doc.submit()
plan.reload()
plan.make_material_request()
mr_item = frappe.db.get_value("Material Request Item", {"production_plan": plan.name}, "parent")
mr_doc = frappe.get_doc("Material Request", mr_item)

# Test 3 : Since Item 2 has been fully requested, it should not be included in the new MR by default
self.assertEqual(len(mr_doc.items), 1)

# Test 4 : Quantity in new MR should be the available quantity from Production Plan
self.assertEqual(mr_doc.items[0].qty, 5.0)

mr_doc.items[0].qty = 6

# Test 5 : Quantity of item cannot exceed available quantity from Production Plan
self.assertRaises(frappe.ValidationError, mr_doc.submit)

def test_production_plan_combine_subassembly(self):
"""
Test combining Sub assembly items belonging to the same BOM in Prod Plan.
Expand Down
Loading

0 comments on commit 1f1c01d

Please sign in to comment.