mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-08 15:42:52 +00:00
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
This commit is contained in:
@@ -4302,6 +4302,154 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
if row.s_warehouse:
|
||||
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):
|
||||
doctype = frappe.qb.DocType("Stock Reservation Entry")
|
||||
|
||||
@@ -87,6 +87,9 @@ frappe.ui.form.on("Work Order", {
|
||||
frm.set_indicator_formatter("operation", function (doc) {
|
||||
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) {
|
||||
@@ -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) {
|
||||
let transaction_controller = new erpnext.TransactionController();
|
||||
transaction_controller.autofill_warehouse(
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
"has_batch_no",
|
||||
"column_break_18",
|
||||
"batch_size",
|
||||
"additional_costs_section",
|
||||
"non_stock_items",
|
||||
"secondary_items_section",
|
||||
"secondary_items",
|
||||
"reference_section",
|
||||
"project",
|
||||
"subcontracting_inward_order",
|
||||
@@ -703,6 +707,30 @@
|
||||
"fieldname": "production_item_info_section",
|
||||
"fieldtype": "Section Break",
|
||||
"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,
|
||||
@@ -711,7 +739,7 @@
|
||||
"image_field": "image",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-19 12:20:38.102403",
|
||||
"modified": "2026-06-03 21:35:34.175667",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order",
|
||||
|
||||
@@ -76,6 +76,9 @@ class WorkOrder(Document):
|
||||
if TYPE_CHECKING:
|
||||
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_operation.work_order_operation import WorkOrderOperation
|
||||
|
||||
@@ -106,6 +109,7 @@ class WorkOrder(Document):
|
||||
max_producible_qty: DF.Float
|
||||
mps: DF.Link | None
|
||||
naming_series: DF.Literal["MFG-WO-.YYYY.-"]
|
||||
non_stock_items: DF.Table[WorkOrderAdditionalItem]
|
||||
operations: DF.Table[WorkOrderOperation]
|
||||
planned_end_date: DF.Datetime | None
|
||||
planned_operating_cost: DF.Currency
|
||||
@@ -124,6 +128,7 @@ class WorkOrder(Document):
|
||||
sales_order: DF.Link | None
|
||||
sales_order_item: DF.Data | None
|
||||
scrap_warehouse: DF.Link | None
|
||||
secondary_items: DF.Table[WorkOrderAdditionalItem]
|
||||
skip_transfer: DF.Check
|
||||
source_warehouse: DF.Link | None
|
||||
status: DF.Literal[
|
||||
@@ -167,6 +172,69 @@ class WorkOrder(Document):
|
||||
if based_on := frappe.get_cached_value("BOM", self.bom_no, "backflush_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):
|
||||
jc_doctype = frappe.qb.DocType("Job Card")
|
||||
query = (
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user