feat: show non stock items and secondary items in work order (backport #55631) (#55636)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
This commit is contained in:
mergify[bot]
2026-06-05 05:06:50 +00:00
committed by GitHub
parent bda915a201
commit 3f983c9e4d
7 changed files with 384 additions and 1 deletions

View File

@@ -4302,6 +4302,154 @@ class TestWorkOrder(ERPNextTestSuite):
if row.s_warehouse: if row.s_warehouse:
self.assertIn(row.item_code, [raw_material_1, raw_material_2]) self.assertIn(row.item_code, [raw_material_1, raw_material_2])
def test_non_stock_items_shown_in_work_order(self):
"""Non stock, non phantom raw materials should appear in non_stock_items with scaled qty & amount."""
fg_item = make_item("_Test WO Non Stock FG", {"is_stock_item": 1}).name
stock_rm = make_item(
"_Test WO Non Stock - Stock RM", {"is_stock_item": 1, "valuation_rate": 100}
).name
non_stock_rm = make_item(
"_Test WO Non Stock - Non Stock RM", {"is_stock_item": 0, "valuation_rate": 7}
).name
bom = frappe.get_doc(
{
"doctype": "BOM",
"item": fg_item,
"currency": "INR",
"quantity": 8,
"company": "_Test Company",
}
)
bom.append("items", {"item_code": stock_rm, "qty": 5})
bom.append("items", {"item_code": non_stock_rm, "qty": 3})
bom.insert()
bom.submit()
wo_order = make_wo_order_test_record(
production_item=fg_item, bom_no=bom.name, qty=20, skip_transfer=1, do_not_save=True
)
non_stock_items = wo_order.non_stock_items
# only the non stock, non phantom item is shown; the stock item is excluded
self.assertEqual(len(non_stock_items), 1)
row = non_stock_items[0]
self.assertEqual(row.item_code, non_stock_rm)
# qty = (bom_item_qty / bom_qty) * wo_qty = (3 / 8) * 20 = 7.5
self.assertEqual(flt(row.qty, 6), 7.5)
# amount = base_rate * qty = 7 * 7.5 = 52.5
self.assertEqual(flt(row.amount, 6), 52.5)
def test_secondary_items_from_bom_without_manufacture_entry(self):
"""Without any manufacture entry, secondary items are derived from the BOM with scaled qty & amount."""
fg_item = make_item("_Test WO Sec BOM FG", {"is_stock_item": 1}).name
stock_rm = make_item("_Test WO Sec BOM RM", {"is_stock_item": 1, "valuation_rate": 100}).name
scrap_item = make_item("_Test WO Sec BOM Scrap", {"is_stock_item": 1, "valuation_rate": 0}).name
bom = frappe.get_doc(
{
"doctype": "BOM",
"item": fg_item,
"currency": "INR",
"quantity": 8,
"company": "_Test Company",
}
)
bom.append("items", {"item_code": stock_rm, "qty": 2})
bom.append(
"secondary_items",
{
"type": "Scrap",
"item_code": scrap_item,
"item_name": scrap_item,
"qty": 3,
"cost_allocation_per": 25,
"process_loss_per": 0,
},
)
bom.insert()
bom.submit()
# cost = raw_material_cost * (cost_allocation_per / 100) = 200 * 0.25 = 50
self.assertEqual(flt(bom.secondary_items[0].cost, 6), 50.0)
wo_order = make_wo_order_test_record(
production_item=fg_item, bom_no=bom.name, qty=20, skip_transfer=1
)
secondary_items = wo_order.secondary_items
self.assertEqual(len(secondary_items), 1)
row = secondary_items[0]
self.assertEqual(row.item_code, scrap_item)
self.assertEqual(row.type, "Scrap")
# data is fetched from the BOM (carries bom_qty)
self.assertEqual(flt(row.bom_qty), 8.0)
# qty = (bom_secondary_qty / bom_qty) * wo_qty = (3 / 8) * 20 = 7.5
self.assertEqual(flt(row.qty, 6), 7.5)
# amount = cost * qty = 50 * 7.5 = 375
self.assertEqual(flt(row.amount, 6), 375.0)
def test_secondary_items_reflect_manufacture_entry(self):
"""Once a manufacture entry exists, secondary items reflect what was generated, not the BOM."""
fg_item = make_item("_Test WO Sec SE FG", {"is_stock_item": 1}).name
stock_rm = make_item("_Test WO Sec SE RM", {"is_stock_item": 1, "valuation_rate": 100}).name
scrap_item = make_item("_Test WO Sec SE Scrap", {"is_stock_item": 1, "valuation_rate": 0}).name
bom = frappe.get_doc(
{
"doctype": "BOM",
"item": fg_item,
"currency": "INR",
"quantity": 8,
"company": "_Test Company",
}
)
bom.append("items", {"item_code": stock_rm, "qty": 2})
bom.append(
"secondary_items",
{
"type": "Scrap",
"item_code": scrap_item,
"item_name": scrap_item,
"qty": 3,
"cost_allocation_per": 25,
"process_loss_per": 0,
},
)
bom.insert()
bom.submit()
wo_order = make_wo_order_test_record(
production_item=fg_item,
bom_no=bom.name,
qty=20,
skip_transfer=1,
source_warehouse="_Test Warehouse - _TC",
)
# before any manufacture entry, data comes from the BOM
self.assertEqual(flt(wo_order.secondary_items[0].qty, 6), 7.5)
# make raw material available and manufacture a partial quantity
test_stock_entry.make_stock_entry(
item_code=stock_rm, target="_Test Warehouse - _TC", qty=100, basic_rate=100
)
manufacture_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 8))
manufacture_entry.submit()
generated_row = next(row for row in manufacture_entry.items if row.type == "Scrap")
wo_order.reload()
secondary_items = wo_order.secondary_items
self.assertEqual(len(secondary_items), 1)
row = secondary_items[0]
# now sourced from the manufacture entry, not the BOM
self.assertIsNone(row.get("bom_qty"))
self.assertEqual(row.item_code, scrap_item)
self.assertEqual(flt(row.qty, 6), flt(generated_row.qty, 6))
self.assertEqual(flt(row.amount, 6), flt(generated_row.amount, 6))
# generated qty (3.0 for 8 units) differs from the BOM-scaled qty (7.5 for 20 units)
self.assertEqual(flt(row.qty, 6), 3.0)
def get_reserved_entries(voucher_no, warehouse=None): def get_reserved_entries(voucher_no, warehouse=None):
doctype = frappe.qb.DocType("Stock Reservation Entry") doctype = frappe.qb.DocType("Stock Reservation Entry")

View File

@@ -87,6 +87,9 @@ frappe.ui.form.on("Work Order", {
frm.set_indicator_formatter("operation", function (doc) { frm.set_indicator_formatter("operation", function (doc) {
return frm.doc.qty == doc.completed_qty ? "green" : "orange"; return frm.doc.qty == doc.completed_qty ? "green" : "orange";
}); });
frm.fields_dict["non_stock_items"].grid.set_column_disp_in_list_view("type", false);
frm.fields_dict["secondary_items"].grid.set_column_disp_in_list_view("rate", false);
}, },
set_company_filters(frm, fieldname) { set_company_filters(frm, fieldname) {
@@ -127,6 +130,15 @@ frappe.ui.form.on("Work Order", {
} }
}, },
onload_post_render(frm) {
const label = frm.doc.__onload?.secondary_items_generated
? __("Secondary Items (as per Manufacture Entries)")
: __("Secondary Items (as per BOM)");
frm.set_df_property("secondary_items", "label", label);
frm.fields_dict["secondary_items"].grid.wrapper?.find("> .control-label").text(label);
},
source_warehouse: function (frm) { source_warehouse: function (frm) {
let transaction_controller = new erpnext.TransactionController(); let transaction_controller = new erpnext.TransactionController();
transaction_controller.autofill_warehouse( transaction_controller.autofill_warehouse(

View File

@@ -71,6 +71,10 @@
"has_batch_no", "has_batch_no",
"column_break_18", "column_break_18",
"batch_size", "batch_size",
"additional_costs_section",
"non_stock_items",
"secondary_items_section",
"secondary_items",
"reference_section", "reference_section",
"project", "project",
"subcontracting_inward_order", "subcontracting_inward_order",
@@ -703,6 +707,30 @@
"fieldname": "production_item_info_section", "fieldname": "production_item_info_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Production Item Info" "label": "Production Item Info"
},
{
"fieldname": "additional_costs_section",
"fieldtype": "Section Break"
},
{
"fieldname": "non_stock_items",
"fieldtype": "Table",
"is_virtual": 1,
"label": "Additional Costs (as per BOM)",
"options": "Work Order Additional Item",
"read_only": 1
},
{
"fieldname": "secondary_items_section",
"fieldtype": "Section Break"
},
{
"fieldname": "secondary_items",
"fieldtype": "Table",
"is_virtual": 1,
"label": "Secondary Items (as per BOM)",
"options": "Work Order Additional Item",
"read_only": 1
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
@@ -711,7 +739,7 @@
"image_field": "image", "image_field": "image",
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-05-19 12:20:38.102403", "modified": "2026-06-03 21:35:34.175667",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Work Order", "name": "Work Order",

View File

@@ -76,6 +76,9 @@ class WorkOrder(Document):
if TYPE_CHECKING: if TYPE_CHECKING:
from frappe.types import DF from frappe.types import DF
from erpnext.manufacturing.doctype.work_order_additional_item.work_order_additional_item import (
WorkOrderAdditionalItem,
)
from erpnext.manufacturing.doctype.work_order_item.work_order_item import WorkOrderItem from erpnext.manufacturing.doctype.work_order_item.work_order_item import WorkOrderItem
from erpnext.manufacturing.doctype.work_order_operation.work_order_operation import WorkOrderOperation from erpnext.manufacturing.doctype.work_order_operation.work_order_operation import WorkOrderOperation
@@ -106,6 +109,7 @@ class WorkOrder(Document):
max_producible_qty: DF.Float max_producible_qty: DF.Float
mps: DF.Link | None mps: DF.Link | None
naming_series: DF.Literal["MFG-WO-.YYYY.-"] naming_series: DF.Literal["MFG-WO-.YYYY.-"]
non_stock_items: DF.Table[WorkOrderAdditionalItem]
operations: DF.Table[WorkOrderOperation] operations: DF.Table[WorkOrderOperation]
planned_end_date: DF.Datetime | None planned_end_date: DF.Datetime | None
planned_operating_cost: DF.Currency planned_operating_cost: DF.Currency
@@ -124,6 +128,7 @@ class WorkOrder(Document):
sales_order: DF.Link | None sales_order: DF.Link | None
sales_order_item: DF.Data | None sales_order_item: DF.Data | None
scrap_warehouse: DF.Link | None scrap_warehouse: DF.Link | None
secondary_items: DF.Table[WorkOrderAdditionalItem]
skip_transfer: DF.Check skip_transfer: DF.Check
source_warehouse: DF.Link | None source_warehouse: DF.Link | None
status: DF.Literal[ status: DF.Literal[
@@ -167,6 +172,69 @@ class WorkOrder(Document):
if based_on := frappe.get_cached_value("BOM", self.bom_no, "backflush_based_on"): if based_on := frappe.get_cached_value("BOM", self.bom_no, "backflush_based_on"):
self.set_onload("backflush_raw_materials_based_on", based_on) self.set_onload("backflush_raw_materials_based_on", based_on)
@property
def secondary_items(self):
parent = frappe.qb.DocType("Stock Entry")
child = frappe.qb.DocType("Stock Entry Detail")
secondary_items_generated = (
frappe.qb.from_(parent)
.join(child)
.on(parent.name == child.parent)
.where(
(parent.work_order == self.name)
& (parent.docstatus == 1)
& ((child.type != "") | (child.is_legacy_scrap_item == 1))
)
.select(
child.item_code,
Case().when(child.is_legacy_scrap_item == 1, "Scrap (Legacy)").else_(child.type).as_("type"),
child.qty,
child.uom,
child.amount,
)
.run(as_dict=True)
)
if secondary_items_generated:
self.set_onload("secondary_items_generated", True)
return secondary_items_generated
else:
secondary_items = frappe.get_query(
"BOM",
filters={"name": self.bom_no},
fields=[
"secondary_items.item_code",
"secondary_items.type",
"secondary_items.qty",
"secondary_items.uom",
"secondary_items.cost as amount",
"quantity as bom_qty",
],
).run(as_dict=True)
secondary_items = [item for item in secondary_items if item.item_code]
for item in secondary_items:
item["qty"] = (item.qty / item.bom_qty) * self.qty
item["amount"] = flt(item.amount) * item.qty
return secondary_items
@property
def non_stock_items(self):
non_stock_items = frappe.get_query(
"BOM",
filters={"name": self.bom_no, "items.is_stock_item": 0, "items.is_phantom_item": 0},
fields=[
"items.item_code",
"items.qty",
"items.uom",
"items.base_rate as rate",
"items.base_amount as amount",
"quantity as bom_qty",
],
).run(as_dict=True)
for item in non_stock_items:
item["qty"] = (item.qty / item.bom_qty) * self.qty
item["amount"] = item.rate * item["qty"]
return non_stock_items
def show_create_job_card_button(self): def show_create_job_card_button(self):
jc_doctype = frappe.qb.DocType("Job Card") jc_doctype = frappe.qb.DocType("Job Card")
query = ( query = (

View File

@@ -0,0 +1,77 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2026-06-03 15:52:39.829793",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"item_code",
"type",
"qty",
"uom",
"column_break_lrsc",
"rate",
"amount"
],
"fields": [
{
"fieldname": "item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item Code",
"options": "Item"
},
{
"fieldname": "type",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Type"
},
{
"fieldname": "column_break_lrsc",
"fieldtype": "Column Break"
},
{
"fieldname": "qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Qty"
},
{
"fieldname": "uom",
"fieldtype": "Link",
"in_list_view": 1,
"label": "UOM",
"options": "UOM"
},
{
"fieldname": "amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Amount"
},
{
"fieldname": "rate",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Rate"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_virtual": 1,
"istable": 1,
"links": [],
"modified": "2026-06-03 21:46:46.738564",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Additional Item",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,50 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class WorkOrderAdditionalItem(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
amount: DF.Currency
item_code: DF.Link | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
qty: DF.Float
rate: DF.Currency
type: DF.Data | None
uom: DF.Link | None
# end: auto-generated types
@staticmethod
def get_list(self, *args, **kwargs):
pass
@staticmethod
def get_count(self, *args, **kwargs):
pass
@staticmethod
def get_stats(self, *args, **kwargs):
pass
def db_insert(self, *args, **kwargs):
pass
def load_from_db(self, *args, **kwargs):
pass
def db_update(self, *args, **kwargs):
pass
def delete(self, *args, **kwargs):
pass