fix(stock): allow zero completed quantity and handle process loss in job cards (#59104)

(cherry picked from commit 1d8ce1ee8c)
This commit is contained in:
Afsal Syed
2026-09-16 20:14:32 +05:30
committed by Afsal Syed
parent c42df7ef3e
commit 4f984aeecf
3 changed files with 89 additions and 9 deletions

View File

@@ -264,7 +264,6 @@ frappe.ui.form.on("Job Card", {
fieldtype: "Float",
label: __("Completed Quantity"),
fieldname: "completed_qty",
reqd: 1,
default: pending_qty,
change() {
const dialog = frm.job_completion_dialog;
@@ -389,8 +388,8 @@ frappe.ui.form.on("Job Card", {
frm.job_completion_dialog = frappe.prompt(
fields,
(data) => {
if (data.qty <= 0) {
frappe.throw(__("Quantity should be greater than 0"));
if (data.completed_qty < 0) {
frappe.throw(__("Completed Quantity cannot be negative"));
}
frm.call({

View File

@@ -194,10 +194,12 @@ class JobCard(Document):
).format(self.name)
)
if self.docstatus == 1 and not self.total_completed_qty:
if self.docstatus == 1 and not (
self.total_completed_qty or self.process_loss_qty or self.pending_qty
):
frappe.throw(
_(
"Total Completed Qty is required for Job Card {0}, please start and complete the job card before submission"
"Completed, Process Loss or Pending Qty is required for Job Card {0}, please start and complete the job card before submission"
).format(self.name)
)
@@ -951,9 +953,10 @@ class JobCard(Document):
def set_process_loss(self):
precision = self.precision("total_completed_qty")
should_set_process_loss = self.total_completed_qty or self.process_loss_qty
self.process_loss_qty = 0.0
if self.total_completed_qty and self.for_quantity > self.total_completed_qty:
if should_set_process_loss and self.for_quantity > self.total_completed_qty:
self.process_loss_qty = (
flt(self.for_quantity, precision)
- flt(self.total_completed_qty, precision)
@@ -1543,7 +1546,7 @@ class JobCard(Document):
row.to_time = kwargs.to_time
row.time_in_mins = time_diff_in_minutes(row.to_time, row.from_time)
if kwargs.completed_qty:
if kwargs.get("completed_qty") is not None:
row.completed_qty = kwargs.completed_qty
row.db_update()
else:
@@ -1559,13 +1562,13 @@ class JobCard(Document):
for employee in kwargs.employees:
kwargs.employee = employee.get("employee")
if kwargs.from_time and not kwargs.to_time:
if kwargs.qty:
if kwargs.get("qty") is not None:
kwargs.completed_qty = kwargs.qty
row = self.append("time_logs", kwargs)
row.db_update()
self.db_set("status", "Work In Progress")
elif not kwargs.from_time and not kwargs.to_time and kwargs.completed_qty:
elif not kwargs.from_time and not kwargs.to_time and kwargs.get("completed_qty") is not None:
update_status = True
for row in self.time_logs:
if row.employee != kwargs.employee:
@@ -1659,6 +1662,9 @@ class JobCard(Document):
frappe.throw(_("Submitted Job Card cannot be processed."))
def validate_complete_job_card_qty(self, kwargs):
if flt(kwargs.qty) < 0:
frappe.throw(_("Completed quantity cannot be negative."))
if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) < 0:
frappe.throw(_("Pending quantity cannot be negative."))

View File

@@ -1136,6 +1136,50 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(flt(job_card.total_completed_qty), 3)
self.assertEqual(flt(job_card.process_loss_qty), 0)
def test_completion_allows_zero_completed_qty(self):
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
job_card = self.get_first_job_card(work_order.name)
job_card.append("time_logs", {"from_time": "2024-03-01 08:00:00"})
job_card.save()
job_card.complete_job_card(
qty=0,
for_quantity=5,
pending_qty=0,
process_loss_qty=5,
end_time="2024-03-01 09:00:00",
)
job_card.reload()
self.assertEqual(flt(job_card.total_completed_qty), 0)
self.assertEqual(flt(job_card.process_loss_qty), 5)
job_card.submit()
self.assertEqual(job_card.docstatus, 1)
def test_completion_overwrites_existing_completed_qty_with_zero(self):
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
job_card = self.get_first_job_card(work_order.name)
job_card.append("time_logs", {"from_time": "2024-03-01 08:00:00", "completed_qty": 5})
job_card.complete_job_card(
qty=0,
for_quantity=5,
pending_qty=0,
process_loss_qty=5,
end_time="2024-03-01 09:00:00",
)
job_card.reload()
self.assertEqual(flt(job_card.total_completed_qty), 0)
self.assertEqual(flt(job_card.process_loss_qty), 5)
self.assertEqual(flt(job_card.time_logs[0].completed_qty), 0)
job_card.submit()
self.assertEqual(job_card.docstatus, 1)
def test_completion_qty_keeps_for_quantity_across_cycles(self):
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
@@ -2975,6 +3019,12 @@ class TestJobCard(ERPNextTestSuite):
jc.validate_completion_qty_split(
frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0)
)
jc.validate_completion_qty_split(
frappe._dict(for_quantity=5, qty=0, pending_qty=0, process_loss_qty=5)
)
jc.validate_completion_qty_split(
frappe._dict(for_quantity=5, qty=0, pending_qty=5, process_loss_qty=0)
)
self.assertRaises(
frappe.ValidationError,
@@ -2988,6 +3038,31 @@ class TestJobCard(ERPNextTestSuite):
frappe._dict(for_quantity=1, qty=0.3334, pending_qty=0.3334, process_loss_qty=0.3334),
)
def test_complete_job_card_qty_guards(self):
jc = frappe.new_doc("Job Card")
jc.for_quantity = 5
self.assertRaises(frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(qty=-1))
def test_set_process_loss(self):
nothing_done = frappe.new_doc("Job Card")
nothing_done.for_quantity = 10
nothing_done.total_completed_qty = 0
nothing_done.set_process_loss()
self.assertEqual(nothing_done.process_loss_qty, 0)
all_process_loss = frappe.new_doc("Job Card")
all_process_loss.for_quantity = 10
all_process_loss.process_loss_qty = 10
all_process_loss.set_process_loss()
self.assertEqual(all_process_loss.process_loss_qty, 10)
def test_zero_completed_qty_is_valid_for_semi_finished_goods(self):
jc = frappe.new_doc("Job Card")
jc.docstatus = 1
jc.track_semi_finished_goods = 1
jc.process_loss_qty = 5
jc.validate_semi_finished_goods()
def create_bom_with_multiple_operations():
"Create a BOM with multiple operations and Material Transfer against Job Card"