diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index d0190ae69ee..2aa60a14d84 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -299,7 +299,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; @@ -424,8 +423,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({ diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 0225ba03a4c..4c56a50a151 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -205,10 +205,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) ) @@ -1013,9 +1015,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) @@ -1639,7 +1642,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: @@ -1655,7 +1658,7 @@ class JobCard(Document): kwargs.employee = employee.get("employee") if kwargs.from_time and not kwargs.to_time: self.add_new_time_log_for_employee(kwargs) - 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: self.update_completed_qty_for_employee(kwargs) update_status = True else: @@ -1665,7 +1668,7 @@ class JobCard(Document): self.set_status(update_status=update_status) def add_new_time_log_for_employee(self, kwargs): - if kwargs.qty: + if kwargs.get("qty") is not None: kwargs.completed_qty = kwargs.qty row = self.append("time_logs", kwargs) @@ -1770,6 +1773,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.")) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index ed5d4427575..5fddf3aa22f 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1330,6 +1330,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) @@ -3424,6 +3468,7 @@ class TestJobCardLogic(ERPNextTestSuite): jc = frappe.new_doc("Job Card") jc.for_quantity = 5 jc.validate_complete_job_card_qty(frappe._dict(pending_qty=3)) # within range -> passes + self.assertRaises(frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(qty=-1)) self.assertRaises( frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=-1) ) @@ -3449,6 +3494,12 @@ class TestJobCardLogic(ERPNextTestSuite): jc.validate_complete_job_card_qty( frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0) ) + jc.validate_complete_job_card_qty( + frappe._dict(for_quantity=5, qty=0, pending_qty=0, process_loss_qty=5) + ) + jc.validate_complete_job_card_qty( + frappe._dict(for_quantity=5, qty=0, pending_qty=5, process_loss_qty=0) + ) self.assertRaises( frappe.ValidationError, @@ -3483,6 +3534,19 @@ class TestJobCardLogic(ERPNextTestSuite): 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 test_capacity_overlap_detection(self): jc = frappe.new_doc("Job Card") sequential = [