From 59c3eef7dbf6c93f5b037d7e317e283f28e98a39 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 19 Nov 2025 13:00:47 +0530 Subject: [PATCH 1/4] fix: stock entry manufacture - fix operating cost calculation --- erpnext/manufacturing/doctype/bom/bom.py | 1 + .../landed_cost_taxes_and_charges.json | 12 ++++++++-- .../landed_cost_taxes_and_charges.py | 1 + .../stock/doctype/stock_entry/stock_entry.py | 23 ++++++++++++++++++- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 754a64e11bb..753d5a6ec4d 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1574,6 +1574,7 @@ def add_operations_cost(stock_entry, work_order=None, expense_account=None, job_ "expense_account": expense_account, "description": _("Operating Cost as per Work Order / BOM"), "amount": operating_cost_per_unit * flt(stock_entry.fg_completed_qty), + "has_operating_cost": 1, }, ) diff --git a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json index dac161a46ff..1bbafc08446 100644 --- a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json +++ b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json @@ -12,7 +12,8 @@ "col_break3", "amount", "base_amount", - "has_corrective_cost" + "has_corrective_cost", + "has_operating_cost" ], "fields": [ { @@ -70,13 +71,20 @@ "fieldtype": "Check", "label": "Has Corrective Cost", "read_only": 1 + }, + { + "default": "0", + "fieldname": "has_operating_cost", + "fieldtype": "Check", + "label": "Has Operating Cost", + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-06-09 10:22:20.286641", + "modified": "2025-07-16 15:27:59.175530", "modified_by": "Administrator", "module": "Stock", "name": "Landed Cost Taxes and Charges", diff --git a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py index a3f7f037d60..a4fb129a7ae 100644 --- a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py +++ b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py @@ -21,6 +21,7 @@ class LandedCostTaxesandCharges(Document): exchange_rate: DF.Float expense_account: DF.Link | None has_corrective_cost: DF.Check + has_operating_cost: DF.Check parent: DF.Data parentfield: DF.Data parenttype: DF.Data diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e67192a4299..540de088c42 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3418,6 +3418,25 @@ def get_work_order_details(work_order, company): def get_operating_cost_per_unit(work_order=None, bom_no=None): + def get_consumed_operating_cost(wo_name, bom_no): + table = frappe.qb.DocType("Stock Entry") + child_table = frappe.qb.DocType("Landed Cost Taxes and Charges") + query = ( + frappe.qb.from_(child_table) + .join(table) + .on(child_table.parent == table.name) + .select(Sum(child_table.amount).as_("consumed_cost")) + .where( + (table.docstatus == 1) + & (table.work_order == wo_name) + & (table.purpose == "Manufacture") + & (table.bom_no == bom_no) + & (child_table.has_operating_cost == 1) + ) + ) + cost = query.run(pluck="consumed_cost") + return cost[0] if cost and cost[0] else 0 + operating_cost_per_unit = 0 if work_order: if ( @@ -3434,7 +3453,9 @@ def get_operating_cost_per_unit(work_order=None, bom_no=None): for d in work_order.get("operations"): if flt(d.completed_qty): - operating_cost_per_unit += flt(d.actual_operating_cost) / flt(d.completed_qty) + operating_cost_per_unit += flt( + d.actual_operating_cost - get_consumed_operating_cost(work_order.name, bom_no) + ) / flt(d.completed_qty - work_order.produced_qty) elif work_order.qty: operating_cost_per_unit += flt(d.planned_operating_cost) / flt(work_order.qty) From 37b120bf69adab1d1f266678f8ae50302f6b7128 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 19 Nov 2025 15:56:13 +0530 Subject: [PATCH 2/4] fix: modify for new changes --- erpnext/manufacturing/doctype/bom/bom.py | 18 ++++++--- .../stock/doctype/stock_entry/stock_entry.py | 37 ++++++++++--------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 753d5a6ec4d..2c3d8b1279b 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1503,7 +1503,7 @@ def add_non_stock_items_cost(stock_entry, work_order, expense_account, job_card= def add_operating_cost_component_wise( - stock_entry, work_order=None, operating_cost_per_unit=None, op_expense_account=None, job_card=None + stock_entry, work_order=None, consumed_operating_cost=None, op_expense_account=None, job_card=None ): if not work_order: return False @@ -1527,11 +1527,11 @@ def add_operating_cost_component_wise( get_component_account(wc.operating_component, stock_entry.company) or op_expense_account ) actual_cp_operating_cost = flt( - flt(wc.operating_cost) * flt(flt(row.actual_operation_time) / 60.0), + flt(wc.operating_cost) * flt(flt(row.actual_operation_time) / 60.0) - consumed_operating_cost, row.precision("actual_operating_cost"), ) - per_unit_cost = flt(actual_cp_operating_cost) / flt(row.completed_qty) + per_unit_cost = flt(actual_cp_operating_cost) / flt(row.completed_qty - work_order.produced_qty) if per_unit_cost and expense_account: stock_entry.append( @@ -1542,6 +1542,7 @@ def add_operating_cost_component_wise( wc.operating_component, row.operation ), "amount": per_unit_cost * flt(stock_entry.fg_completed_qty), + "has_operating_cost": 1, }, ) @@ -1558,13 +1559,20 @@ def get_component_account(parent, company): def add_operations_cost(stock_entry, work_order=None, expense_account=None, job_card=None): - from erpnext.stock.doctype.stock_entry.stock_entry import get_operating_cost_per_unit + from erpnext.stock.doctype.stock_entry.stock_entry import ( + get_consumed_operating_cost, + get_operating_cost_per_unit, + ) operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no) if operating_cost_per_unit: cost_added = add_operating_cost_component_wise( - stock_entry, work_order, operating_cost_per_unit, expense_account, job_card=job_card + stock_entry, + work_order, + get_consumed_operating_cost(work_order.name, stock_entry.bom_no), + expense_account, + job_card=job_card, ) if not cost_added: diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 540de088c42..b386955f9a5 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3417,26 +3417,27 @@ def get_work_order_details(work_order, company): } -def get_operating_cost_per_unit(work_order=None, bom_no=None): - def get_consumed_operating_cost(wo_name, bom_no): - table = frappe.qb.DocType("Stock Entry") - child_table = frappe.qb.DocType("Landed Cost Taxes and Charges") - query = ( - frappe.qb.from_(child_table) - .join(table) - .on(child_table.parent == table.name) - .select(Sum(child_table.amount).as_("consumed_cost")) - .where( - (table.docstatus == 1) - & (table.work_order == wo_name) - & (table.purpose == "Manufacture") - & (table.bom_no == bom_no) - & (child_table.has_operating_cost == 1) - ) +def get_consumed_operating_cost(wo_name, bom_no): + table = frappe.qb.DocType("Stock Entry") + child_table = frappe.qb.DocType("Landed Cost Taxes and Charges") + query = ( + frappe.qb.from_(child_table) + .join(table) + .on(child_table.parent == table.name) + .select(Sum(child_table.amount).as_("consumed_cost")) + .where( + (table.docstatus == 1) + & (table.work_order == wo_name) + & (table.purpose == "Manufacture") + & (table.bom_no == bom_no) + & (child_table.has_operating_cost == 1) ) - cost = query.run(pluck="consumed_cost") - return cost[0] if cost and cost[0] else 0 + ) + cost = query.run(pluck="consumed_cost") + return cost[0] if cost and cost[0] else 0 + +def get_operating_cost_per_unit(work_order=None, bom_no=None): operating_cost_per_unit = 0 if work_order: if ( From 0973dbac65f0a855220b1bc6a682ae05e8e6b275 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 19 Nov 2025 15:56:38 +0530 Subject: [PATCH 3/4] fix: create job card button --- erpnext/manufacturing/doctype/work_order/work_order.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index b651b211e11..6cf512f7288 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -166,9 +166,10 @@ class WorkOrder(Document): operation_details = frappe._dict( frappe.get_all( "Job Card", - fields=["operation", "for_quantity"], + fields=["operation", "sum(for_quantity)"], filters={"docstatus": ("<", 2), "work_order": self.name}, as_list=1, + group_by="operation_id", ) ) From 3327799524e8239843c44842b6fb4251de69528d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 19 Nov 2025 15:57:15 +0530 Subject: [PATCH 4/4] test: add test case --- .../doctype/job_card/test_job_card.py | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 4ab07321bd2..425367c519d 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -708,6 +708,119 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(wo_doc.process_loss_qty, 2) self.assertEqual(wo_doc.status, "Completed") + def test_op_cost_calculation(self): + from erpnext.manufacturing.doctype.routing.test_routing import ( + create_routing, + setup_bom, + setup_operations, + ) + from erpnext.manufacturing.doctype.work_order.work_order import make_job_card + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_for_wo, + ) + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + make_workstation(workstation_name="Test Workstation Z", hour_rate_rent=240) + operations = [ + {"operation": "Test Operation A1", "workstation": "Test Workstation Z", "time_in_mins": 30}, + ] + + warehouse = create_warehouse("Test Warehouse 123 for Job Card") + setup_operations(operations) + + item_code = "Test Job Card Process Qty Item" + for item in [item_code, item_code + "RM 1", item_code + "RM 2"]: + if not frappe.db.exists("Item", item): + make_item( + item, + { + "item_name": item, + "stock_uom": "Nos", + "is_stock_item": 1, + }, + ) + + routing_doc = create_routing(routing_name="Testing Route", operations=operations) + bom_doc = setup_bom( + item_code=item_code, + routing=routing_doc.name, + raw_materials=[item_code + "RM 1", item_code + "RM 2"], + source_warehouse=warehouse, + ) + + for row in bom_doc.items: + make_stock_entry( + item_code=row.item_code, + target=row.source_warehouse, + qty=10, + basic_rate=100, + ) + + wo_doc = make_wo_order_test_record( + production_item=item_code, + bom_no=bom_doc.name, + qty=10, + skip_transfer=1, + wip_warehouse=warehouse, + source_warehouse=warehouse, + ) + + first_job_card = frappe.get_all( + "Job Card", + filters={"work_order": wo_doc.name, "sequence_id": 1}, + fields=["name"], + order_by="sequence_id", + limit=1, + )[0].name + + jc = frappe.get_doc("Job Card", first_job_card) + for _ in jc.scheduled_time_logs: + jc.append( + "time_logs", + { + "from_time": now(), + "to_time": add_to_date(now(), minutes=1), + "completed_qty": 4, + }, + ) + jc.for_quantity = 4 + jc.save() + jc.submit() + + s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 4)) + s.submit() + + self.assertEqual(s.additional_costs[0].amount, 4) + + make_job_card( + wo_doc.name, + [ + { + "name": wo_doc.operations[0].name, + "operation": "Test Operation A1", + "qty": 6, + "pending_qty": 6, + } + ], + ) + + job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name}) + job_card.append( + "time_logs", + { + "from_time": add_to_date(now(), hours=1), + "to_time": add_to_date(now(), hours=1, minutes=2), + "completed_qty": 6, + }, + ) + job_card.for_quantity = 6 + job_card.save() + job_card.submit() + + s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6)) + self.assertEqual(s.additional_costs[0].amount, 8) + def create_bom_with_multiple_operations(): "Create a BOM with multiple operations and Material Transfer against Job Card"