fix(manufacturing): account for process loss in Production Plan Work Orders (backport #58799) (#58838)

This commit is contained in:
Mihir Kandoi
2026-09-08 09:50:47 +05:30
committed by GitHub
parent f6dbb3131d
commit cfdf97601a
8 changed files with 769 additions and 74 deletions

View File

@@ -31,6 +31,9 @@ from erpnext.manufacturing.doctype.bom.bom import add_additional_cost, get_bom_i
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import ( from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
get_mins_between_operations, get_mins_between_operations,
) )
from erpnext.manufacturing.doctype.production_plan.work_order_quantities import (
ProductionPlanWorkOrderQuantities,
)
from erpnext.manufacturing.doctype.workstation_type.workstation_type import get_workstations from erpnext.manufacturing.doctype.workstation_type.workstation_type import get_workstations
from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import (
get_subcontracting_boms_for_finished_goods, get_subcontracting_boms_for_finished_goods,
@@ -990,6 +993,10 @@ class JobCard(Document):
if not self.operation_id: if not self.operation_id:
return return
work_order = frappe.get_doc("Work Order", self.work_order)
if work_order.production_plan:
ProductionPlanWorkOrderQuantities(work_order.production_plan).lock_plan_row(work_order)
job_cards = frappe.get_all( job_cards = frappe.get_all(
"Job Card", "Job Card",
filters={ filters={
@@ -1004,14 +1011,13 @@ class JobCard(Document):
completed_qty = sum(max(flt(row.manufactured_qty), flt(row.total_completed_qty)) for row in job_cards) completed_qty = sum(max(flt(row.manufactured_qty), flt(row.total_completed_qty)) for row in job_cards)
frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", completed_qty) frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", completed_qty)
if ( if self.finished_good and work_order.production_item == self.finished_good:
self.finished_good work_order.db_set("produced_qty", sum(flt(row.manufactured_qty) for row in job_cards))
and frappe.get_cached_value("Work Order", self.work_order, "production_item") if work_order.production_plan:
== self.finished_good ProductionPlanWorkOrderQuantities(work_order.production_plan).validate_work_order(
): work_order, process_loss_qty=work_order.process_loss_qty
_wo_doc = frappe.get_doc("Work Order", self.work_order) )
_wo_doc.db_set("produced_qty", sum(flt(row.manufactured_qty) for row in job_cards)) work_order.db_set("status", work_order.get_status())
_wo_doc.db_set("status", _wo_doc.get_status())
def update_corrective_in_work_order(self, wo): def update_corrective_in_work_order(self, wo):
wo.corrective_operation_cost = 0.0 wo.corrective_operation_cost = 0.0

View File

@@ -230,6 +230,14 @@ frappe.ui.form.on("Production Plan", {
let has_items = let has_items =
items.filter((item) => { items.filter((item) => {
const reference_field =
item.doctype === "Production Plan Item"
? "production_plan_item"
: "production_plan_sub_assembly_item";
const pending_qty = frm.doc.__onload?.pending_work_order_qty?.[reference_field]?.[item.name];
if (pending_qty !== undefined) {
return pending_qty > 0;
}
if (item.planned_qty) { if (item.planned_qty) {
return item.planned_qty > item.ordered_qty; return item.planned_qty > item.ordered_qty;
} else { } else {

View File

@@ -31,6 +31,9 @@ from pypika.terms import ExistsCriterion
from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children
from erpnext.manufacturing.doctype.bom.bom import validate_bom_no from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
from erpnext.manufacturing.doctype.production_plan.work_order_quantities import (
ProductionPlanWorkOrderQuantities,
)
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.item.item import get_uom_conv_factor from erpnext.stock.doctype.item.item import get_uom_conv_factor
@@ -122,6 +125,12 @@ class ProductionPlan(Document):
frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"), frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"),
) )
if self.docstatus == 1:
self.set_onload(
"pending_work_order_qty",
ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self),
)
def on_discard(self): def on_discard(self):
self.db_set("status", "Cancelled") self.db_set("status", "Cancelled")
@@ -728,6 +737,7 @@ class ProductionPlan(Document):
def get_production_items(self): def get_production_items(self):
item_dict = {} item_dict = {}
pending = ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self)
for d in self.po_items: for d in self.po_items:
item_details = { item_details = {
@@ -750,29 +760,12 @@ class ProductionPlan(Document):
"project": self.project, "project": self.project,
} }
key = (d.item_code, d.sales_order, d.sales_order_item, d.warehouse, d.planned_start_date)
if self.combine_items:
key = (d.item_code, d.sales_order, d.warehouse, d.planned_start_date)
if not d.sales_order:
key = (d.name, d.item_code, d.warehouse, d.planned_start_date)
if not item_details["project"] and d.sales_order: if not item_details["project"] and d.sales_order:
item_details["project"] = frappe.get_cached_value("Sales Order", d.sales_order, "project") item_details["project"] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
if self.get_items_from == "Material Request": item_details["qty"] = pending["production_plan_item"][d.name]
item_details.update({"qty": d.planned_qty}) # A Work Order can reference only one Production Plan row.
item_dict[ item_dict[d.name] = item_details
(d.item_code, d.material_request_item, d.warehouse, d.planned_start_date)
] = item_details
else:
item_details.update(
{
"qty": flt(item_dict.get(key, {}).get("qty"))
+ (flt(d.planned_qty) - flt(d.ordered_qty))
}
)
item_dict[key] = item_details
return item_dict return item_dict
@@ -780,6 +773,7 @@ class ProductionPlan(Document):
def make_work_order(self): def make_work_order(self):
from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
self.reload()
wo_list, po_list = [], [] wo_list, po_list = [], []
subcontracted_po = {} subcontracted_po = {}
default_warehouses = get_default_warehouse(self.company) default_warehouses = get_default_warehouse(self.company)
@@ -809,6 +803,7 @@ class ProductionPlan(Document):
wo_list.append(work_order) wo_list.append(work_order)
def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses): def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses):
pending = ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self)
for row in self.sub_assembly_items: for row in self.sub_assembly_items:
if row.type_of_manufacturing == "Subcontract": if row.type_of_manufacturing == "Subcontract":
subcontracted_po.setdefault(row.supplier, []).append(row) subcontracted_po.setdefault(row.supplier, []).append(row)
@@ -825,10 +820,9 @@ class ProductionPlan(Document):
"company": self.get("company"), "company": self.get("company"),
} }
if flt(row.qty) <= flt(row.ordered_qty): self.prepare_data_for_sub_assembly_items(
continue row, work_order_data, pending["production_plan_sub_assembly_item"][row.name]
)
self.prepare_data_for_sub_assembly_items(row, work_order_data)
if work_order_data.get("qty") <= 0: if work_order_data.get("qty") <= 0:
continue continue
@@ -837,7 +831,7 @@ class ProductionPlan(Document):
if work_order: if work_order:
wo_list.append(work_order) wo_list.append(work_order)
def prepare_data_for_sub_assembly_items(self, row, wo_data): def prepare_data_for_sub_assembly_items(self, row, wo_data, pending_qty=None):
for field in [ for field in [
"production_item", "production_item",
"item_name", "item_name",
@@ -853,7 +847,11 @@ class ProductionPlan(Document):
if row.get(field): if row.get(field):
wo_data[field] = row.get(field) wo_data[field] = row.get(field)
wo_data["qty"] = flt(row.get("qty")) - flt(row.get("ordered_qty")) if pending_qty is None:
pending_qty = ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self)[
"production_plan_sub_assembly_item"
][row.name]
wo_data["qty"] = pending_qty
wo_data.update( wo_data.update(
{ {

View File

@@ -1567,12 +1567,12 @@ class TestProductionPlan(ERPNextTestSuite):
def test_multiple_work_order_for_production_plan_item(self): def test_multiple_work_order_for_production_plan_item(self):
"Test producing Prod Plan (making WO) in parts." "Test producing Prod Plan (making WO) in parts."
def create_work_order(item, pln, qty): def create_work_order(pln, qty):
# Get Production Items # Get Production Items
items_data = pln.get_production_items() items_data = pln.get_production_items()
# Update qty # Update qty
items_data[(pln.po_items[0].name, item, None, pln.po_items[0].planned_start_date)]["qty"] = qty items_data[pln.po_items[0].name]["qty"] = qty
# Create and Submit Work Order for each item in items_data # Create and Submit Work Order for each item in items_data
for _key, item in items_data.items(): for _key, item in items_data.items():
@@ -1600,17 +1600,17 @@ class TestProductionPlan(ERPNextTestSuite):
wo_list = [] wo_list = []
# Create and Submit 1st Work Order for 3 qty # Create and Submit 1st Work Order for 3 qty
create_work_order(item, pln, 3) create_work_order(pln, 3)
pln.reload() pln.reload()
self.assertEqual(pln.po_items[0].ordered_qty, 3) self.assertEqual(pln.po_items[0].ordered_qty, 3)
# Create and Submit 2nd Work Order for 2 qty # Create and Submit 2nd Work Order for 2 qty
create_work_order(item, pln, 2) create_work_order(pln, 2)
pln.reload() pln.reload()
self.assertEqual(pln.po_items[0].ordered_qty, 5) self.assertEqual(pln.po_items[0].ordered_qty, 5)
# Overproduction # Overproduction
self.assertRaises(OverProductionError, create_work_order, item=item, pln=pln, qty=2) self.assertRaises(OverProductionError, create_work_order, pln=pln, qty=2)
# Cancel 1st Work Order # Cancel 1st Work Order
wo1 = frappe.get_doc("Work Order", wo_list[0]) wo1 = frappe.get_doc("Work Order", wo_list[0])
@@ -1791,8 +1791,11 @@ class TestProductionPlan(ERPNextTestSuite):
make_bom(item=fg_item, raw_materials=[sub_assembly_item], rm_qty=4) make_bom(item=fg_item, raw_materials=[sub_assembly_item], rm_qty=4)
# Step - 1: Create Production Plan # Step - 1: Create Production Plan
pln = create_production_plan(item_code=fg_item, planned_qty=5, skip_getting_mr_items=1) pln = create_production_plan(
item_code=fg_item, planned_qty=5, skip_getting_mr_items=1, do_not_submit=1
)
pln.get_sub_assembly_items() pln.get_sub_assembly_items()
pln.submit()
# Step - 2: Create Work Orders # Step - 2: Create Work Orders
pln.make_work_order() pln.make_work_order()

View File

@@ -0,0 +1,532 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from contextlib import contextmanager
from unittest.mock import patch
import frappe
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.production_plan.test_production_plan import (
create_production_plan,
make_bom,
)
from erpnext.manufacturing.doctype.production_plan.work_order_quantities import (
ProductionPlanWorkOrderQuantities,
)
from erpnext.manufacturing.doctype.work_order.work_order import (
OverProductionError,
StockOverProductionError,
close_work_order,
stop_unstop,
)
from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as make_se_from_wo
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
REFERENCE_FIELDS = ("production_plan_item", "production_plan_sub_assembly_item")
class TestProductionPlanWorkOrderQuantities(ERPNextTestSuite):
def setUp(self):
self.warehouse = "_Test Warehouse - _TC"
self.raw_material, self.sub_assembly, self.finished_good = (
make_item(properties={"is_stock_item": 1, "stock_uom": "Kg", "valuation_rate": 10}).name
for _ in range(3)
)
for item, material in (
(self.sub_assembly, self.raw_material),
(self.finished_good, self.sub_assembly),
):
bom = make_bom(item=item, raw_materials=[material], do_not_save=True)
bom.process_loss_percentage = 10
bom.insert().submit()
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 0)
def test_quantity_limit_on_submit(self):
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.qty = 50
first.submit()
second = self.create_work_order(plan, field)
self.assertEqual(second.qty, 50)
self.assert_overproduction(second, 60)
second.qty = 50
second.submit()
self.assert_pending_qty(plan, field, 0)
def test_recorded_loss_creates_replacement(self):
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
self.assert_pending_qty(plan, field, 0)
manufacture = self.manufacture_with_loss(first)
first.reload()
self.assertEqual(first.process_loss_qty, 10)
self.assertEqual(first.produced_qty, 90)
self.assert_pending_qty(plan, field, 10)
replacement = self.create_work_order(plan, field)
self.assertEqual(replacement.qty, 10)
self.assert_overproduction(replacement, 11)
replacement.qty = 10
replacement.submit()
self.assert_pending_qty(plan, field, 0)
row = self.plan_row(plan, field)
self.assertEqual(row.ordered_qty, 110)
self.assert_loss_reversal_blocked(manufacture)
first.reload()
self.assertEqual(first.process_loss_qty, 10)
self.assertEqual(first.produced_qty, 90)
self.assert_pending_qty(plan, field, 0)
replacement.cancel()
manufacture.cancel()
self.assertEqual(first.reload().process_loss_qty, 0)
first.reload().cancel()
self.assert_pending_qty(plan, field, 100)
def test_cumulative_manufacture_loss_exceeds_work_order(self):
plan = self.make_plan()
for field in (None, *REFERENCE_FIELDS):
with self.subTest(field=field):
first = self.create_work_order(plan, field or "production_plan_item")
if field is None:
first.production_plan = None
first.production_plan_item = None
first.qty = 100
first.submit()
self.manufacture_with_loss(first, loss_qty=99)
second_entry = self.manufacture_with_loss(first, loss_qty=99, submit=False)
self.assert_manufacture_rejected(second_entry, StockOverProductionError)
self.assertEqual(first.reload().produced_qty, 1)
self.assertEqual(first.process_loss_qty, 99)
if field:
self.assert_pending_qty(plan, field, 99)
replacement = self.create_work_order(plan, field)
replacement.submit()
self.assert_pending_qty(plan, field, 0)
def test_cumulative_manufacture_loss_respects_allowance(self):
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 10)
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
self.manufacture_with_loss(first, qty=50)
self.manufacture_with_loss(first, qty=50)
last_entry = self.manufacture_with_loss(first, qty=10)
self.assertEqual(first.reload().produced_qty, 99)
self.assertEqual(first.process_loss_qty, 11)
self.assert_pending_qty(plan, field, 1)
excess = self.manufacture_with_loss(first, qty=1, submit=False)
self.assert_manufacture_rejected(excess, StockOverProductionError)
excess.delete()
last_entry.cancel()
self.assertEqual(first.reload().produced_qty, 90)
self.assertEqual(first.process_loss_qty, 10)
self.manufacture_with_loss(first, qty=10)
@ERPNextTestSuite.change_settings("System Settings", {"float_precision": 6})
def test_cumulative_fractional_manufacture_loss(self):
plan = self.make_plan(qty=0.3)
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
self.manufacture_with_loss(first, qty=0.1, loss_qty=0.025)
self.manufacture_with_loss(first, qty=0.2, loss_qty=0.05)
self.assertAlmostEqual(first.reload().produced_qty, 0.225)
self.assertAlmostEqual(first.process_loss_qty, 0.075)
excess = self.manufacture_with_loss(first, qty=0.001, submit=False)
self.assert_manufacture_rejected(excess, StockOverProductionError)
def test_existing_excess_loss_preserves_produced_quantity(self):
for field in REFERENCE_FIELDS:
for loss_qty, produced_qty in ((198, 2), (100, 2), (20, 90), (198, 0)):
with self.subTest(field=field, loss_qty=loss_qty, produced_qty=produced_qty):
plan = self.make_plan()
first = self.create_work_order(plan, field)
first.submit()
# Reproduce records saved before cumulative manufacture validation existed.
first.db_set({"process_loss_qty": loss_qty, "produced_qty": produced_qty})
pending_qty = 100 - produced_qty
self.assert_pending_qty(plan, field, pending_qty)
replacement = self.create_work_order(plan, field)
self.assertEqual(replacement.qty, pending_qty)
self.assert_overproduction(replacement, pending_qty + 1)
replacement.qty = pending_qty
replacement.submit()
self.assert_pending_qty(plan, field, 0)
quantities = ProductionPlanWorkOrderQuantities(plan.name)
quantities.validate_work_order(first, process_loss_qty=loss_qty)
with self.assertRaises(OverProductionError):
quantities.validate_work_order(first, process_loss_qty=0)
def test_more_production_cannot_consume_replacement_allowance(self):
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 10)
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
self.manufacture_with_loss(first, loss_qty=99)
replacement = self.create_work_order(plan, field)
replacement.qty = 109
replacement.submit()
# This fits the first Work Order's allowance, but exceeds the plan's 110 units.
excess = self.manufacture_with_loss(first, qty=10, loss_qty=9, submit=False)
self.assert_manufacture_rejected(excess, OverProductionError)
self.assertEqual(first.reload().produced_qty, 1)
self.assertEqual(first.process_loss_qty, 99)
def test_loss_reversal_with_draft_replacement(self):
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
manufacture = self.manufacture_with_loss(first)
replacement = self.create_work_order(plan, field)
manufacture.cancel()
self.assertEqual(first.reload().process_loss_qty, 0)
self.assert_pending_qty(plan, field, 0)
self.assert_overproduction(replacement, 10)
def test_partial_loss_reversal_with_overproduction_allowance(self):
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 5)
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
manufactures = [self.manufacture_with_loss(first, qty=50) for _ in range(2)]
self.assertEqual(first.reload().process_loss_qty, 10)
replacement = self.create_work_order(plan, field)
replacement.submit()
# Retaining five units of loss keeps the net quantity at the allowed 105.
manufactures[1].cancel()
self.assertEqual(first.reload().process_loss_qty, 5)
self.assert_loss_reversal_blocked(manufactures[0])
self.assertEqual(first.reload().process_loss_qty, 5)
replacement.cancel()
manufactures[0].cancel()
self.assertEqual(first.reload().process_loss_qty, 0)
def test_job_card_loss_reversal_with_replacement(self):
self.make_bom_with_operation(self.finished_good, self.raw_material)
plan = self.make_plan()
first = self.create_work_order(plan, "production_plan_item")
first.submit()
job_card = frappe.get_last_doc("Job Card", {"work_order": first.name})
job_card.append("time_logs", {"from_time": "2024-05-01 08:00:00"})
job_card.save()
job_card.complete_job_card(
qty=90,
for_quantity=100,
pending_qty=0,
process_loss_qty=10,
end_time="2024-05-01 09:00:00",
)
job_card.reload().submit()
self.assertEqual(first.reload().process_loss_qty, 10)
make_stock_entry(item_code=self.raw_material, target=self.warehouse, qty=100, basic_rate=10)
manufacture = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
replacement = self.create_work_order(plan, "production_plan_item")
replacement.submit()
with self.assert_plan_locked_before_work_order_update(first):
manufacture.cancel()
job_card.reload()
self.assert_loss_reversal_blocked(job_card)
self.assertEqual(first.reload().process_loss_qty, 10)
self.assertEqual(first.operations[0].process_loss_qty, 10)
replacement.cancel()
job_card.cancel()
self.assertEqual(first.reload().process_loss_qty, 0)
def test_expected_loss_does_not_allow_extra_quantity(self):
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
work_order = self.create_work_order(plan, field)
self.assert_overproduction(work_order, 110)
def test_overproduction_allowance(self):
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 10)
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
self.assertEqual(first.qty, 100)
first.submit()
self.assert_pending_qty(plan, field, 0)
second = self.copy_work_order(first)
self.assert_overproduction(second, 11)
second.qty = 10
second.submit()
def test_drafts_and_cancelled_orders_do_not_consume_quantity(self):
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
second = self.create_work_order(plan, field)
self.assertEqual(first.qty, second.qty)
first.submit()
self.assert_overproduction(second, 100)
first.cancel()
second.submit()
self.assert_pending_qty(plan, field, 0)
def test_process_loss_and_overproduction_allowance(self):
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 10)
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.qty = 50
first.submit()
self.manufacture_with_loss(first)
second = self.create_work_order(plan, field)
self.assertEqual(second.qty, 55)
self.assert_overproduction(second, 66)
second.qty = 65
second.submit()
self.assert_pending_qty(plan, field, 0)
def test_stopped_and_closed_orders_consume_quantity(self):
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
stop_unstop(first.name, "Stopped")
self.assert_pending_qty(plan, field, 0)
stop_unstop(first.name, "Not Started")
close_work_order(first.name, "Closed")
self.assert_pending_qty(plan, field, 0)
def test_fractional_quantities(self):
plan = self.make_plan(qty=0.3)
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.qty = 0.1
first.submit()
second = self.create_work_order(plan, field)
self.assertEqual(second.qty, 0.2)
second.submit()
self.assert_pending_qty(plan, field, 0)
def test_rows_with_same_item_are_independent(self):
plan = self.make_plan(submit=False)
sales_order = make_sales_order(item_code=self.finished_good, qty=200, warehouse=self.warehouse)
plan.po_items[0].sales_order = sales_order.name
plan.po_items[0].sales_order_item = sales_order.items[0].name
row = plan.po_items[0].as_dict()
row.pop("name")
row["planned_qty"] = 50
plan.append("po_items", row)
plan.submit()
first = self.create_work_order(plan, "production_plan_item")
first.submit()
plan.onload()
pending = plan.get_onload()["pending_work_order_qty"]["production_plan_item"]
self.assertEqual(pending[plan.po_items[0].name], 0)
self.assertEqual(pending[plan.po_items[1].name], 50)
second_name = frappe.db.get_value(
"Work Order", {"production_plan_item": plan.po_items[1].name, "docstatus": 0}, "name"
)
second = frappe.get_doc("Work Order", second_name)
self.assertEqual(second.qty, 50)
self.assert_overproduction(second, 51)
def test_material_request_plan_uses_remaining_quantity(self):
plan = self.make_plan(submit=False)
plan.get_items_from = "Material Request"
plan.submit()
first = self.create_work_order(plan, "production_plan_item")
first.qty = 50
first.submit()
second = self.create_work_order(plan, "production_plan_item")
self.assertEqual(second.qty, 50)
def test_reference_must_belong_to_plan(self):
plan = self.make_plan()
other_plan = self.make_plan()
work_order = self.create_work_order(plan, "production_plan_item")
work_order.production_plan = other_plan.name
with self.assertRaisesRegex(frappe.ValidationError, "must reference a row"):
work_order.submit()
def test_missing_or_ambiguous_plan_reference(self):
plan = self.make_plan()
work_order = self.create_work_order(plan, "production_plan_item")
work_order.production_plan_sub_assembly_item = plan.sub_assembly_items[0].name
with self.assertRaisesRegex(frappe.ValidationError, "only one Production Plan row"):
work_order.submit()
work_order.reload()
work_order.production_plan_item = None
with self.assertRaisesRegex(frappe.ValidationError, "must reference a row"):
work_order.submit()
def make_plan(self, qty=100, submit=True):
plan = create_production_plan(
item_code=self.finished_good,
planned_qty=qty,
stock_uom="Kg",
warehouse=self.warehouse,
sub_assembly_warehouse=self.warehouse,
skip_getting_mr_items=True,
do_not_submit=True,
)
plan.get_sub_assembly_items()
if submit:
plan.submit()
return plan
def make_bom_with_operation(self, item, material):
bom = make_bom(item=item, raw_materials=[material], with_operations=1, do_not_save=True)
bom.track_semi_finished_goods = 1
bom.items[0].operation_row_id = 1
operation = {
"operation": f"_Test Loss Reversal {item}",
"workstation": "_Test Workstation A",
"finished_good": item,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": self.warehouse,
"fg_warehouse": self.warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation)
make_operation(operation)
bom.append("operations", operation)
bom.insert().submit()
def create_work_order(self, plan, field):
plan.make_work_order()
name = frappe.db.get_value(
"Work Order",
{"production_plan": plan.name, field: self.plan_row(plan, field).name, "docstatus": 0},
"name",
order_by="creation desc",
)
work_order = frappe.get_doc("Work Order", name)
work_order.update(
{"skip_transfer": 1, "source_warehouse": self.warehouse, "fg_warehouse": self.warehouse}
)
return work_order
def copy_work_order(self, work_order):
copy = frappe.copy_doc(work_order)
copy.docstatus = 0
copy.production_plan = work_order.production_plan
for field in REFERENCE_FIELDS:
copy.set(field, work_order.get(field))
copy.insert()
return copy
def manufacture_with_loss(self, work_order, qty=None, *, loss_qty=None, submit=True):
for item in work_order.required_items:
make_stock_entry(
item_code=item.item_code, target=self.warehouse, qty=item.required_qty, basic_rate=10
)
entry = frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", qty or work_order.qty))
if loss_qty is not None:
entry.process_loss_qty = loss_qty
entry.process_loss_percentage = loss_qty / entry.fg_completed_qty * 100
for item in entry.items:
if item.is_finished_item:
item.qty = entry.fg_completed_qty - loss_qty
if submit:
entry.submit()
else:
entry.save()
return entry
def assert_manufacture_rejected(self, entry, exception):
frappe.db.savepoint("excess_manufacture")
try:
with self.assertRaises(exception):
entry.submit()
finally:
frappe.db.rollback(save_point="excess_manufacture")
self.assertEqual(entry.reload().docstatus, 0)
def assert_loss_reversal_blocked(self, document):
work_order = frappe.get_doc("Work Order", document.work_order)
frappe.db.savepoint("loss_reversal")
try:
with (
self.assert_plan_locked_before_work_order_update(work_order),
self.assertRaises(OverProductionError),
):
document.cancel()
finally:
# Match the request rollback after an on_cancel validation fails.
frappe.db.rollback(save_point="loss_reversal")
self.assertEqual(document.reload().docstatus, 1)
@contextmanager
def assert_plan_locked_before_work_order_update(self, work_order):
get_value, set_value = frappe.db.get_value, frappe.db.set_value
plan_row_locked = False
row_doctype = (
"Production Plan Sub Assembly Item"
if work_order.production_plan_sub_assembly_item
else "Production Plan Item"
)
row_name = work_order.production_plan_sub_assembly_item or work_order.production_plan_item
def get_value_with_lock_check(doctype, filters=None, *args, **kwargs):
nonlocal plan_row_locked
if doctype == "Work Order" and filters == work_order.name and kwargs.get("for_update"):
self.assertTrue(plan_row_locked, "Work Order locked before its Production Plan row")
result = get_value(doctype, filters, *args, **kwargs)
if (
doctype == row_doctype
and filters == {"name": row_name, "parent": work_order.production_plan}
and kwargs.get("for_update")
):
plan_row_locked = True
return result
def set_value_with_lock_check(doctype, name, *args, **kwargs):
if doctype == "Work Order" and name == work_order.name:
self.assertTrue(plan_row_locked, "Work Order updated before locking its Production Plan row")
return set_value(doctype, name, *args, **kwargs)
with (
patch.object(frappe.db, "get_value", get_value_with_lock_check),
patch.object(frappe.db, "set_value", set_value_with_lock_check),
):
yield
def assert_overproduction(self, work_order, qty):
work_order.qty = qty
work_order.save()
with self.assertRaises(OverProductionError):
work_order.submit()
work_order.reload()
def assert_pending_qty(self, plan, field, expected):
plan.reload()
plan.onload()
self.assertEqual(
plan.get_onload()["pending_work_order_qty"][field][self.plan_row(plan, field).name], expected
)
def plan_row(self, plan, field):
return plan.po_items[0] if field == "production_plan_item" else plan.sub_assembly_items[0]

View File

@@ -0,0 +1,143 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from collections import defaultdict
import frappe
from frappe import _
from frappe.utils import flt, get_link_to_form
class ProductionPlanWorkOrderQuantities:
"""Count submitted Work Orders after recorded process loss, independently for each plan row."""
def __init__(self, production_plan):
self.production_plan = production_plan
def validate_work_order(self, work_order, *, process_loss_qty=0):
from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
row = self.lock_plan_row(work_order)
committed = self.get_committed_quantities(
exclude_work_order=work_order.name,
reference_field=row.reference_field,
reference_name=row.name,
for_update=True,
)[row.reference_field].get(row.name, 0)
allowance = flt(
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
)
precision = work_order.precision("qty")
maximum_qty = flt(flt(row.planned_qty) * (1 + allowance / 100) - committed, precision)
committed_qty = flt(self._get_committed_qty(work_order, process_loss_qty), precision)
if committed_qty > maximum_qty:
frappe.throw(
_(
"Row {0} in {1} {2}: Work Order quantity after process loss {3} exceeds the remaining allowed quantity {4}."
).format(
row.idx,
_(row.doctype),
get_link_to_form("Production Plan", self.production_plan),
committed_qty,
max(0, maximum_qty),
),
OverProductionError,
title=_("Production Plan Quantity Exceeded"),
)
def lock_plan_row(self, work_order):
if work_order.production_plan_item and work_order.production_plan_sub_assembly_item:
frappe.throw(_("Work Order must reference only one Production Plan row."))
if work_order.production_plan_sub_assembly_item:
reference_field = "production_plan_sub_assembly_item"
row_doctype, qty_field = "Production Plan Sub Assembly Item", "qty"
else:
reference_field = "production_plan_item"
row_doctype, qty_field = "Production Plan Item", "planned_qty"
reference_name = work_order.get(reference_field)
# Serialize submissions and loss reversals. The submit rollup updates this row.
row = (
frappe.db.get_value(
row_doctype,
{"name": reference_name, "parent": self.production_plan},
["name", "idx", f"{qty_field} as planned_qty"],
as_dict=True,
for_update=True,
)
if reference_name
else None
)
if not row:
frappe.throw(
_("Work Order must reference a row in Production Plan {0}.").format(
get_link_to_form("Production Plan", self.production_plan)
)
)
row.reference_field = reference_field
row.doctype = row_doctype
return row
def get_pending_quantities(self, plan):
committed = self.get_committed_quantities()
precision = frappe.get_precision("Work Order", "qty")
pending = {}
for table, reference_field, qty_field in (
("po_items", "production_plan_item", "planned_qty"),
("sub_assembly_items", "production_plan_sub_assembly_item", "qty"),
):
pending[reference_field] = {
row.name: max(
0, flt(flt(row.get(qty_field)) - committed[reference_field].get(row.name, 0), precision)
)
for row in plan.get(table)
if table == "po_items" or row.type_of_manufacturing == "In House"
}
return pending
def get_committed_quantities(
self, exclude_work_order=None, reference_field=None, reference_name=None, for_update=False
):
table = frappe.qb.DocType("Work Order")
query = (
frappe.qb.from_(table)
.select(
table.production_plan_item,
table.production_plan_sub_assembly_item,
table.qty,
table.produced_qty,
table.process_loss_qty,
)
.where((table.production_plan == self.production_plan) & (table.docstatus == 1))
.orderby(table.name)
)
if exclude_work_order:
query = query.where(table.name != exclude_work_order)
if reference_field:
query = query.where(table[reference_field] == reference_name)
# Use a current locking read so concurrent submissions see committed quantities.
if for_update:
query = query.for_update()
work_orders = query.run(as_dict=True)
quantities = {
"production_plan_item": defaultdict(float),
"production_plan_sub_assembly_item": defaultdict(float),
}
for work_order in work_orders:
field = (
"production_plan_sub_assembly_item"
if work_order.production_plan_sub_assembly_item
else "production_plan_item"
)
if work_order.get(field):
quantities[field][work_order[field]] += self._get_committed_qty(
work_order, work_order.process_loss_qty
)
return quantities
def _get_committed_qty(self, work_order, process_loss_qty):
# Excess loss in existing records must not erase finished goods already produced.
return max(0, flt(work_order.produced_qty), flt(work_order.qty) - flt(process_loss_qty))

View File

@@ -34,6 +34,9 @@ from erpnext.manufacturing.doctype.bom.bom import (
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import ( from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
get_mins_between_operations, get_mins_between_operations,
) )
from erpnext.manufacturing.doctype.production_plan.work_order_quantities import (
ProductionPlanWorkOrderQuantities,
)
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.batch.batch import make_batch from erpnext.stock.doctype.batch.batch import make_batch
from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
@@ -799,6 +802,8 @@ class WorkOrder(Document):
if self.track_semi_finished_goods: if self.track_semi_finished_goods:
return return
# Lock the plan row before any Work Order quantity update takes a row lock.
self.set_process_loss_qty()
allowance_percentage = flt( allowance_percentage = flt(
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order") frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
) )
@@ -825,16 +830,26 @@ class WorkOrder(Document):
) )
completed_qty = self.qty + (allowance_percentage / 100 * self.qty) completed_qty = self.qty + (allowance_percentage / 100 * self.qty)
if qty > completed_qty: qty_to_validate = qty + flt(self.process_loss_qty) if purpose == "Manufacture" else qty
precision = self.precision(fieldname)
if flt(qty_to_validate, precision) > flt(completed_qty, precision):
frappe.throw( frappe.throw(
_("{0} ({1}) cannot be greater than planned quantity ({2}) in Work Order {3}").format( _("{0} ({1}) cannot be greater than planned quantity ({2}) in Work Order {3}").format(
_(self.meta.get_label(fieldname)), qty, completed_qty, self.name _("Manufactured Qty (including Process Loss)")
if purpose == "Manufacture"
else _(self.meta.get_label(fieldname)),
flt(qty_to_validate, precision),
completed_qty,
self.name,
), ),
StockOverProductionError, StockOverProductionError,
) )
self.db_set(fieldname, qty) self.db_set(fieldname, qty)
self.set_process_loss_qty() if purpose == "Manufacture" and self.production_plan:
ProductionPlanWorkOrderQuantities(self.production_plan).validate_work_order(
self, process_loss_qty=self.process_loss_qty
)
from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item
@@ -905,7 +920,20 @@ class WorkOrder(Document):
return flt(query.run()[0][0]) return flt(query.run()[0][0])
def set_process_loss_qty(self): def set_process_loss_qty(self):
self.db_set("process_loss_qty", self._process_loss_qty()) quantities = None
if self.docstatus == 1 and self.production_plan:
quantities = ProductionPlanWorkOrderQuantities(self.production_plan)
quantities.lock_plan_row(self)
process_loss_qty = self._process_loss_qty()
if quantities:
previous_loss_qty = frappe.db.get_value(
"Work Order", self.name, "process_loss_qty", for_update=True
)
if process_loss_qty < flt(previous_loss_qty):
# Replacement Work Orders may have consumed the recorded loss.
quantities.validate_work_order(self, process_loss_qty=process_loss_qty)
self.db_set("process_loss_qty", process_loss_qty)
def _process_loss_qty(self): def _process_loss_qty(self):
if self.track_semi_finished_goods: if self.track_semi_finished_goods:
@@ -950,6 +978,8 @@ class WorkOrder(Document):
frappe.throw(_("Target Warehouse is required before Submit")) frappe.throw(_("Target Warehouse is required before Submit"))
def before_submit(self): def before_submit(self):
if self.production_plan:
ProductionPlanWorkOrderQuantities(self.production_plan).validate_work_order(self)
self.create_serial_no_batch_no() self.create_serial_no_batch_no()
def on_submit(self): def on_submit(self):
@@ -1622,36 +1652,6 @@ class WorkOrder(Document):
), ),
) )
if self.production_plan and self.production_plan_item and not self.production_plan_sub_assembly_item:
qty_dict = frappe.db.get_value(
"Production Plan Item", self.production_plan_item, ["planned_qty", "ordered_qty"], as_dict=1
)
if not qty_dict:
return
allowance_qty = (
flt(
frappe.db.get_single_value(
"Manufacturing Settings", "overproduction_percentage_for_work_order"
)
)
/ 100
* qty_dict.get("planned_qty", 0)
)
max_qty = qty_dict.get("planned_qty", 0) + allowance_qty - qty_dict.get("ordered_qty", 0)
if max_qty <= 0:
frappe.throw(
_("Cannot produce more item for {0}").format(self.production_item), OverProductionError
)
elif self.qty > max_qty:
frappe.throw(
_("Cannot produce more than {0} items for {1}").format(max_qty, self.production_item),
OverProductionError,
)
if self.subcontracting_inward_order and self.qty > self.max_producible_qty: if self.subcontracting_inward_order and self.qty > self.max_producible_qty:
frappe.msgprint( frappe.msgprint(
_( _(

View File

@@ -33,6 +33,9 @@ from erpnext.manufacturing.doctype.bom.bom import (
get_secondary_items_from_sub_assemblies, get_secondary_items_from_sub_assemblies,
validate_bom_no, validate_bom_no,
) )
from erpnext.manufacturing.doctype.production_plan.work_order_quantities import (
ProductionPlanWorkOrderQuantities,
)
from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.brand.brand import get_brand_defaults
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.batch.batch import get_batch_qty from erpnext.stock.doctype.batch.batch import get_batch_qty
@@ -2517,6 +2520,8 @@ class StockEntry(StockController, SubcontractingInwardController):
if self.work_order: if self.work_order:
pro_doc = frappe.get_doc("Work Order", self.work_order) pro_doc = frappe.get_doc("Work Order", self.work_order)
_validate_work_order(pro_doc) _validate_work_order(pro_doc)
if pro_doc.production_plan:
ProductionPlanWorkOrderQuantities(pro_doc.production_plan).lock_plan_row(pro_doc)
if self.fg_completed_qty: if self.fg_completed_qty:
if self.docstatus == 1: if self.docstatus == 1: