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

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

* fix: revert job card finish dialog changes

(cherry picked from commit b68324ce78)

# Conflicts:
#	erpnext/manufacturing/doctype/job_card/job_card.py
#	erpnext/manufacturing/doctype/work_order/work_order.js
This commit is contained in:
rohitwaghchaure
2026-08-18 13:26:56 +05:30
committed by Mergify
parent dd4d10862d
commit 08e1afc46f
3 changed files with 229 additions and 1 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")
@@ -1441,7 +1443,59 @@ class JobCard(Document):
.groupby(job_card.operation_id)
).run()
<<<<<<< HEAD
return dict(data)
=======
return {row.operation_id: row for row in data}
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
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(self, row, current_operation_qty):
if not row.completed_qty or (row.status != "Completed" and row.completed_qty < current_operation_qty):
frappe.throw(
_(
"Job Card {0}: As per the sequence of the operations in the work order {1}, complete the operation {2} before the operation {3}."
).format(
bold(self.name),
bold(get_link_to_form("Work Order", self.work_order)),
bold(row.operation),
bold(self.operation),
),
OperationSequenceError,
)
if row.completed_qty < current_operation_qty:
frappe.throw(
_(
"The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}."
).format(
bold(self.get_qty_with_uom(current_operation_qty)),
bold(self.operation),
bold(self.get_qty_with_uom(row.completed_qty, row.finished_good)),
bold(row.operation),
)
)
>>>>>>> b68324ce78 (fix: work order finish dialog with process loss qty from job card (#58256))
def validate_previous_operation_manufactured_qty(self, row, current_operation_qty):
manufactured_qty = flt(row.manufactured_qty)

View File

@@ -915,6 +915,110 @@ 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.mapper 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 get_first_job_card(self, work_order):
return frappe.get_doc(
"Job Card",

View File

@@ -1021,6 +1021,38 @@ erpnext.work_order = {
return flt(max, precision("qty"));
},
<<<<<<< HEAD
=======
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"));
},
get_max_requestable_qty: (frm) => {
const required = {};
const covered = {};
(frm.doc.required_items || []).forEach((row) => {
required[row.item_code] = (required[row.item_code] || 0) + flt(row.required_qty);
if (!(row.item_code in covered)) {
covered[row.item_code] =
flt(row.transferred_qty) + flt(row.requested_qty) + flt(row.picked_qty);
}
});
let max_fraction = 0;
Object.keys(required).forEach((item_code) => {
if (required[item_code] <= 0) return;
const pending = required[item_code] - covered[item_code];
max_fraction = Math.max(max_fraction, pending / required[item_code]);
});
return flt(max_fraction * flt(frm.doc.qty), precision("qty"));
},
>>>>>>> b68324ce78 (fix: work order finish dialog with process loss qty from job card (#58256))
show_disassembly_prompt: function (frm) {
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
@@ -1075,8 +1107,18 @@ erpnext.work_order = {
});
},
<<<<<<< HEAD
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;
=======
show_prompt_for_qty_input: function (frm, purpose, { qty, additional_transfer_entry, target } = {}) {
let max = qty == null ? 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;
>>>>>>> b68324ce78 (fix: work order finish dialog with process loss qty from job card (#58256))
let fields = [
{
@@ -1085,9 +1127,18 @@ 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"))
);
}
},
},
];
<<<<<<< HEAD
if (!additional_transfer_entry) {
fields.push({
fieldtype: "Check",
@@ -1100,8 +1151,27 @@ erpnext.work_order = {
} 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."),
>>>>>>> b68324ce78 (fix: work order finish dialog with process loss qty from job card (#58256))
},
});
{
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) => {