fix: work order finish dialog with process loss qty from job card (backport #58256) (#58262)

* fix(manufacturing): cap job card completed qty by previous operation and show process loss on finish dialog

* fix: avoid double booking process loss on partial manufacture entries
This commit is contained in:
rohitwaghchaure
2026-08-18 15:17:32 +05:30
committed by GitHub
parent 9b0db26c25
commit 27a04d8e08
4 changed files with 222 additions and 21 deletions

View File

@@ -150,6 +150,8 @@ class JobCard(Document):
self.set_onload("job_card_excess_transfer", excess_transfer)
self.set_onload("work_order_closed", self.is_work_order_closed())
self.set_onload("has_stock_entry", self.has_stock_entry())
if self.docstatus == 0:
self.set_onload("max_completable_qty", self.get_max_completable_qty())
def on_discard(self):
self.db_set("status", "Cancelled")
@@ -1374,12 +1376,7 @@ class JobCard(Document):
current_operation_qty += flt(self.total_completed_qty)
previous_operations = frappe.get_all(
"Work Order Operation",
fields=["name", "operation", "status", "completed_qty", "sequence_id", "finished_good"],
filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)},
order_by="sequence_id, idx",
)
previous_operations = self.get_previous_operations()
message = "Job Card {}: As per the sequence of the operations in the work order {}".format(
bold(self.name), bold(get_link_to_form("Work Order", self.work_order))
@@ -1443,6 +1440,41 @@ class JobCard(Document):
return dict(data)
def get_previous_operations(self):
return frappe.get_all(
"Work Order Operation",
fields=["name", "operation", "status", "completed_qty", "sequence_id", "finished_good"],
filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)},
order_by="sequence_id, idx",
)
def get_current_operation_completed_qty(self):
current_operation_qty = 0.0
data = self.get_current_operation_data()
if data and len(data) > 0:
current_operation_qty = flt(data[0].completed_qty)
return current_operation_qty + flt(self.total_completed_qty)
def get_max_completable_qty(self):
if self.is_corrective_job_card or not (self.work_order and self.sequence_id):
return None
previous_operations = self.get_previous_operations()
if not previous_operations:
return None
if self.track_semi_finished_goods:
totals = self.get_manufactured_qty_per_operation([row.name for row in previous_operations])
for row in previous_operations:
row.manufactured_qty = flt(totals.get(row.name))
qty_field = "manufactured_qty" if self.track_semi_finished_goods else "completed_qty"
min_completed_qty = min(flt(row.get(qty_field)) for row in previous_operations)
precision = self.precision("total_completed_qty")
return flt(min_completed_qty - self.get_current_operation_completed_qty(), precision)
def validate_previous_operation_manufactured_qty(self, row, current_operation_qty):
manufactured_qty = flt(row.manufactured_qty)

View File

@@ -915,6 +915,146 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(wo_doc.process_loss_qty, 2)
self.assertEqual(wo_doc.status, "Completed")
def make_two_operation_work_order(self, qty=10):
from erpnext.manufacturing.doctype.routing.test_routing import (
create_routing,
setup_bom,
setup_operations,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
operations = [
{"operation": "Test Operation A1", "workstation": "Test Workstation A", "time_in_mins": 30},
{"operation": "Test Operation B1", "workstation": "Test Workstation A", "time_in_mins": 20},
]
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=qty, basic_rate=100)
return make_wo_order_test_record(
production_item=item_code,
bom_no=bom_doc.name,
qty=qty,
skip_transfer=1,
wip_warehouse=warehouse,
source_warehouse=warehouse,
)
def test_completion_qty_capped_by_previous_operation(self):
wo_doc = self.make_two_operation_work_order()
job_cards = frappe.get_all(
"Job Card",
filters={"work_order": wo_doc.name},
fields=["name", "sequence_id"],
order_by="sequence_id",
)
jc1 = frappe.get_doc("Job Card", job_cards[0].name)
self.assertIsNone(jc1.get_max_completable_qty())
jc1.append(
"time_logs",
{"from_time": now(), "to_time": add_to_date(now(), minutes=30), "completed_qty": 8},
)
jc1.save()
jc1.submit()
self.assertEqual(jc1.process_loss_qty, 2)
jc2 = frappe.get_doc("Job Card", job_cards[1].name)
self.assertEqual(jc2.get_max_completable_qty(), 8)
jc2.append("time_logs", {"from_time": add_to_date(now(), minutes=40)})
jc2.save()
self.assertRaises(
frappe.ValidationError,
jc2.complete_job_card,
qty=10,
for_quantity=10,
pending_qty=0,
process_loss_qty=0,
end_time=add_to_date(now(), minutes=70),
)
self.complete_second_operation_and_finish(wo_doc, jc2.name)
def complete_second_operation_and_finish(self, wo_doc, job_card):
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_for_wo,
)
jc2 = frappe.get_doc("Job Card", job_card)
jc2.time_logs[0].completed_qty = 7
jc2.time_logs[0].to_time = add_to_date(now(), minutes=70)
jc2.save()
self.assertEqual(jc2.process_loss_qty, 3)
jc2.submit()
se = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 10))
se.submit()
self.assertEqual(se.process_loss_qty, 3)
fg_qty = sum(d.qty for d in se.items if d.is_finished_item)
self.assertEqual(flt(fg_qty), 7)
wo_doc.reload()
self.assertEqual(wo_doc.produced_qty, 7)
self.assertEqual(wo_doc.process_loss_qty, 3)
self.assertEqual(wo_doc.status, "Completed")
def test_process_loss_booked_once_across_partial_entries(self):
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_for_wo,
)
wo_doc = self.make_two_operation_work_order()
job_cards = frappe.get_all(
"Job Card", filters={"work_order": wo_doc.name}, fields=["name"], order_by="sequence_id"
)
for index, row in enumerate(job_cards):
jc = frappe.get_doc("Job Card", row.name)
from_time = add_to_date(now(), minutes=index * 40)
jc.append(
"time_logs",
{"from_time": from_time, "to_time": add_to_date(from_time, minutes=30), "completed_qty": 7},
)
jc.save()
jc.submit()
self.assertEqual(jc.process_loss_qty, 3)
se1 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 5))
se1.submit()
self.assertEqual(se1.process_loss_qty, 3)
self.assertEqual(flt(sum(d.qty for d in se1.items if d.is_finished_item)), 2)
se2 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 5))
se2.submit()
self.assertEqual(flt(se2.process_loss_qty), 0)
self.assertEqual(flt(sum(d.qty for d in se2.items if d.is_finished_item)), 5)
wo_doc.reload()
self.assertEqual(wo_doc.process_loss_qty, 3)
self.assertEqual(wo_doc.produced_qty, 7)
self.assertEqual(wo_doc.status, "Completed")
def get_first_job_card(self, work_order):
return frappe.get_doc(
"Job Card",

View File

@@ -1021,6 +1021,15 @@ erpnext.work_order = {
return flt(max, precision("qty"));
},
get_pending_operation_process_loss: (frm) => {
if (!(frm.doc.operations || []).length) {
return 0;
}
const total_loss = Math.max(...frm.doc.operations.map((row) => flt(row.process_loss_qty)));
return flt(Math.max(total_loss - flt(frm.doc.process_loss_qty), 0), precision("qty"));
},
show_disassembly_prompt: function (frm) {
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
@@ -1077,6 +1086,11 @@ erpnext.work_order = {
show_prompt_for_qty_input: function (frm, purpose, qty, additional_transfer_entry) {
let max = !additional_transfer_entry ? this.get_max_transferable_qty(frm, purpose) : qty;
if (purpose === "Manufacture") {
max = flt(Math.max(max - flt(frm.doc.process_loss_qty), 0), precision("qty"));
}
const pending_process_loss =
purpose === "Manufacture" ? this.get_pending_operation_process_loss(frm) : 0;
let fields = [
{
@@ -1085,23 +1099,36 @@ erpnext.work_order = {
fieldname: "qty",
description: __("Max: {0}", [max]),
default: max,
onchange: function () {
if (pending_process_loss && frm.qty_prompt) {
frm.qty_prompt.set_value(
"finished_good_qty",
flt(Math.max(flt(this.value) - pending_process_loss, 0), precision("qty"))
);
}
},
},
];
if (!additional_transfer_entry) {
fields.push({
fieldtype: "Check",
label: __("Consider Process Loss"),
fieldname: "consider_process_loss",
default: 0,
onchange: function () {
if (this.value) {
frm.qty_prompt.set_value("qty", max - frm.doc.process_loss_qty);
} else {
frm.qty_prompt.set_value("qty", max);
}
if (pending_process_loss) {
fields.push(
{
fieldtype: "Float",
label: __("Process Loss Qty"),
fieldname: "process_loss_qty",
default: pending_process_loss,
read_only: 1,
description: __("Process loss booked against the operations of this work order."),
},
});
{
fieldtype: "Float",
label: __("Finished Good Qty"),
fieldname: "finished_good_qty",
default: flt(Math.max(max - pending_process_loss, 0), precision("qty")),
read_only: 1,
description: __("Actual quantity of the finished good that will be manufactured."),
}
);
}
return new Promise((resolve, reject) => {

View File

@@ -3323,7 +3323,7 @@ class StockEntry(StockController, SubcontractingInwardController):
def get_pending_process_loss_qty(self):
"""Loss this entry should still book: the job card's unbooked loss when the entry
belongs to one, else the largest operation loss on the work order (legacy flow)."""
belongs to one, else the unbooked portion of the largest operation loss on the work order."""
if self.job_card:
job_card = frappe.get_doc("Job Card", self.job_card)
return max(flt(job_card.process_loss_qty) - flt(job_card.get_consumed_process_loss()), 0)
@@ -3334,7 +3334,9 @@ class StockEntry(StockController, SubcontractingInwardController):
filters={"parent": self.work_order},
fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}],
)
return flt(data[0].process_loss_qty) if data else 0
max_operation_loss = flt(data[0].process_loss_qty) if data else 0
booked_loss = flt(frappe.db.get_value("Work Order", self.work_order, "process_loss_qty"))
return max(max_operation_loss - booked_loss, 0)
return 0