diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 0a4026672cf..a4e4d58d253 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -67,7 +67,11 @@ frappe.ui.form.on("Job Card", { if (remaining_qty < frm.doc.pending_qty) { frm.doc.pending_qty = 0.0; refresh_field("pending_qty"); - frappe.throw(__("Pending Quantity cannot be greater than {0}", [remaining_qty])); + frappe.throw( + __("Pending Quantity cannot be greater than {0}", [ + get_qty_with_uom(remaining_qty, frm.doc.stock_uom), + ]) + ); } const process_loss_qty = flt(remaining_qty) - flt(frm.doc.pending_qty); @@ -261,8 +265,28 @@ frappe.ui.form.on("Job Card", { default: pending_qty, change() { const dialog = frm.job_completion_dialog; +<<<<<<< HEAD const remaining = dialog.get_value("for_quantity") - dialog.get_value("completed_qty"); if (remaining > 0 && remaining != dialog.get_value("pending_qty")) { +======= + const remaining = + dialog.get_value("for_quantity") - + dialog.get_value("completed_qty") - + dialog.get_value("process_loss_qty"); + + if (remaining < 0) { + const max_completed_qty = + flt(dialog.get_value("for_quantity")) - flt(dialog.get_value("process_loss_qty")); + dialog.set_value("completed_qty", max_completed_qty); + frappe.throw( + __("Completed Quantity cannot be greater than {0}", [ + get_qty_with_uom(max_completed_qty, frm.doc.stock_uom), + ]) + ); + } + + if (remaining != dialog.get_value("pending_qty")) { +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) dialog.set_value("pending_qty", remaining); } }, @@ -278,7 +302,25 @@ frappe.ui.form.on("Job Card", { dialog.get_value("for_quantity") - dialog.get_value("completed_qty") - dialog.get_value("pending_qty"); +<<<<<<< HEAD if (process_loss_qty >= 0 && process_loss_qty != dialog.get_value("process_loss_qty")) { +======= + + if (process_loss_qty < 0) { + dialog.set_value("pending_qty", 0); + frappe.throw( + __("Pending Quantity cannot be greater than {0}", [ + get_qty_with_uom( + flt(dialog.get_value("for_quantity")) - + flt(dialog.get_value("completed_qty")), + frm.doc.stock_uom + ), + ]) + ); + } + + if (process_loss_qty != dialog.get_value("process_loss_qty")) { +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) dialog.set_value("process_loss_qty", process_loss_qty); } }, @@ -293,7 +335,25 @@ frappe.ui.form.on("Job Card", { dialog.get_value("for_quantity") - dialog.get_value("completed_qty") - dialog.get_value("process_loss_qty"); +<<<<<<< HEAD if (remaining >= 0 && remaining != dialog.get_value("pending_qty")) { +======= + + if (remaining < 0) { + dialog.set_value("process_loss_qty", 0); + frappe.throw( + __("Process Loss Quantity cannot be greater than {0}", [ + get_qty_with_uom( + flt(dialog.get_value("for_quantity")) - + flt(dialog.get_value("completed_qty")), + frm.doc.stock_uom + ), + ]) + ); + } + + if (remaining != dialog.get_value("pending_qty")) { +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) dialog.set_value("pending_qty", remaining); } }, @@ -886,3 +946,7 @@ function get_last_completed_row(time_logs) { function get_last_row(time_logs) { return time_logs[time_logs.length - 1] || {}; } + +function get_qty_with_uom(qty, stock_uom) { + return stock_uom ? `${flt(qty)} ${stock_uom}` : flt(qty); +} diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index c215aee42d4..a6fb3790d89 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -13,10 +13,11 @@ "work_order", "column_break_uqjq", "production_item", + "bom_no", "column_break_qrpg", "for_quantity", "column_break_yecz", - "bom_no", + "stock_uom", "section_break_oisd", "company", "naming_series", @@ -164,6 +165,13 @@ "in_preview": 1, "label": "Qty To Manufacture" }, + { + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "read_only": 1 + }, { "fieldname": "wip_warehouse", "fieldtype": "Link", @@ -695,7 +703,11 @@ "grid_page_length": 50, "is_submittable": 1, "links": [], +<<<<<<< HEAD "modified": "2026-06-19 17:39:42.293242", +======= + "modified": "2026-08-01 14:22:19.926911", +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 3b4f8008f08..db7cab39c44 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -130,6 +130,7 @@ class JobCard(Document): "Cancelled", "Completed", ] + stock_uom: DF.Link | None sub_operations: DF.Table[JobCardOperation] target_warehouse: DF.Link | None time_logs: DF.Table[JobCardTimeLog] @@ -158,6 +159,7 @@ class JobCard(Document): def before_validate(self): self.set_wip_warehouse() + self.set_stock_uom() def validate(self): self.validate_time_logs() @@ -906,11 +908,21 @@ class JobCard(Document): qty_to_manufacture = bold(_("Qty to Manufacture")) frappe.throw( +<<<<<<< HEAD _("The {0} ({1}) must be equal to {2} ({3})").format( total_completed_qty_label, bold(flt(total_completed_qty, precision)), qty_to_manufacture, bold(self.for_quantity), +======= + _( + "Total Completed Qty ({0}), Process Loss Qty ({1}) and Pending Qty ({2}) must add up to the Qty to Manufacture ({3})." + ).format( + bold(self.get_qty_with_uom(self.total_completed_qty)), + bold(self.get_qty_with_uom(self.process_loss_qty)), + bold(self.get_qty_with_uom(self.pending_qty)), + bold(self.get_qty_with_uom(self.for_quantity)), +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) ) ) @@ -1184,6 +1196,51 @@ class JobCard(Document): self.set_status(update_status=True) +<<<<<<< HEAD +======= + def get_job_card_items_transferred_qty(self, ste_doc): + from frappe.query_builder.functions import Sum + + job_card_items = [x.get("job_card_item") for x in ste_doc.get("items") if x.get("job_card_item")] + if not job_card_items: + return {} + + se = frappe.qb.DocType("Stock Entry") + sed = frappe.qb.DocType("Stock Entry Detail") + + query = ( + frappe.qb.from_(sed) + .join(se) + .on(sed.parent == se.name) + .select(sed.job_card_item, Sum(sed.qty)) + .where( + (sed.job_card_item.isin(job_card_items)) + & (se.docstatus == 1) + & (se.purpose == "Material Transfer for Manufacture") + ) + .groupby(sed.job_card_item) + ) + + return frappe._dict(query.run(as_list=True)) + + def validate_over_transfer(self, ste_doc, row, transferred_qty): + "Block over transfer of items if not allowed in settings." + required_qty = frappe.db.get_value("Job Card Item", row.job_card_item, "required_qty") + if flt(transferred_qty) > flt(required_qty): + frappe.throw( + _( + "Row #{0}: Cannot transfer more than Required Qty {1} for Item {2} against Job Card {3}" + ).format( + row.idx, + frappe.bold(self.get_qty_with_uom(required_qty, row.item_code)), + frappe.bold(row.item_code), + ste_doc.job_card, + ), + title=_("Excess Transfer"), + exc=JobCardOverTransferError, + ) + +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) def set_transferred_qty(self, update_status=False): from frappe.query_builder.functions import Sum @@ -1280,10 +1337,66 @@ class JobCard(Document): if self.workstation: self.update_workstation_status() +<<<<<<< HEAD +======= + def get_qty_to_produce(self): + """Qty this job card is expected to produce, the pending qty is left to another job card.""" + return flt(self.for_quantity) - flt(self.pending_qty) + + def get_qty_with_uom(self, qty, item_code=None): + """A quantity in a message reads as a count of nothing without the unit it is measured in.""" + uom = self.stock_uom + if item_code: + uom = frappe.get_cached_value("Item", item_code, "stock_uom") + + return f"{flt(qty, self.precision('total_completed_qty'))} {uom or ''}".strip() + + def set_finished_good_status(self): + # Only reached for a submitted job card (docstatus == 1) with a finished good, see set_status(). + qty_to_produce = self.get_qty_to_produce() + + if (self.manufactured_qty + self.process_loss_qty) >= qty_to_produce: + self.status = "Completed" + elif (self.total_completed_qty + self.process_loss_qty) >= qty_to_produce: + # Production is done and the card is submitted, but the finished goods have not been + # booked into stock yet (Manufacture Stock Entry pending) — distinct from active WIP. + self.status = "To Manufacture" + elif self.transferred_qty > 0 or self.skip_material_transfer: + self.status = "Work In Progress" + + def set_non_semi_fg_status(self): + if self.items: + item_data = frappe.get_all( + "Job Card Item", + filters={"parent": self.name}, + fields=["transferred_qty", "required_qty"], + ) + all_transferred = item_data and all( + flt(d.transferred_qty) >= flt(d.required_qty) for d in item_data + ) + any_transferred = any(flt(d.transferred_qty) > 0 for d in item_data) + + if all_transferred: + self.status = "Material Transferred" + elif any_transferred: + self.status = "Partially Transferred" + elif flt(self.for_quantity) <= flt(self.transferred_qty): + self.status = "Material Transferred" + + if self.time_logs: + self.status = "Work In Progress" + + if self.docstatus == 1 and ( + self.get_qty_to_produce() <= (self.total_completed_qty + self.process_loss_qty) or not self.items + ): + self.status = "Completed" + +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) def set_wip_warehouse(self): if not self.wip_warehouse: self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse") +<<<<<<< HEAD def validate_operation_id(self): if ( self.get("operation_id") @@ -1294,6 +1407,36 @@ class JobCard(Document): != self.operation_id ): work_order = bold(get_link_to_form("Work Order", self.work_order)) +======= + def set_stock_uom(self): + item_code = self.finished_good or self.production_item + if item_code: + self.stock_uom = frappe.get_cached_value("Item", item_code, "stock_uom") + + def set_operation_id(self): + if not (self.work_order and self.operation): + return + + if self.operation_id and self.docstatus != 0: + return + + operation_rows = frappe.get_all( + "Work Order Operation", + filters={"parent": self.work_order, "operation": self.operation}, + pluck="name", + ) + + if self.operation_id: + if operation_rows and self.operation_id not in operation_rows: + frappe.throw( + _("Operation {0} does not belong to the work order {1}").format( + bold(self.operation), get_link_to_form("Work Order", self.work_order) + ) + ) + elif len(operation_rows) == 1: + self.operation_id = operation_rows[0] + elif operation_rows and self.docstatus == 0: +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) frappe.throw( _("Operation {0} does not belong to the work order {1}").format( bold(self.operation), work_order @@ -1339,6 +1482,53 @@ class JobCard(Document): if not (self.work_order and self.sequence_id): return +<<<<<<< HEAD +======= + current_operation_qty = self.get_current_operation_completed_qty() + + for row in self.get_previous_operations(): + if self.track_semi_finished_goods: + self.validate_previous_operation_manufactured_qty(row, current_operation_qty) + else: + self.validate_previous_operation(row, current_operation_qty) + + def get_previous_operations(self): + 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", + ) + + if self.track_semi_finished_goods and previous_operations: + manufactured_qty = self.get_manufactured_qty_per_operation( + [row.name for row in previous_operations] + ) + + for row in previous_operations: + row.manufactured_qty = flt(manufactured_qty.get(row.name)) + + return previous_operations + + def get_manufactured_qty_per_operation(self, operation_ids): + job_card = frappe.qb.DocType("Job Card") + + data = ( + frappe.qb.from_(job_card) + .select(job_card.operation_id, Sum(job_card.manufactured_qty)) + .where( + (job_card.work_order == self.work_order) + & (job_card.docstatus == 1) + & (IfNull(job_card.is_corrective_job_card, 0) == 0) + & (job_card.operation_id.isin(operation_ids)) + ) + .groupby(job_card.operation_id) + ).run() + + return dict(data) + + def get_current_operation_completed_qty(self): +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) current_operation_qty = 0.0 data = self.get_current_operation_data() if data and len(data) > 0: @@ -1353,6 +1543,7 @@ class JobCard(Document): order_by="sequence_id, idx", ) +<<<<<<< HEAD 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)) ) @@ -1364,6 +1555,17 @@ class JobCard(Document): message, 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), +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) ) if row.status != "Completed" and row.completed_qty < current_operation_qty: @@ -1374,6 +1576,7 @@ class JobCard(Document): OperationSequenceError, ) +<<<<<<< HEAD if row.completed_qty < current_operation_qty: frappe.throw( _( @@ -1385,6 +1588,33 @@ class JobCard(Document): bold(row.operation), ) ) +======= + if not manufactured_qty: + frappe.throw( + _( + "Job Card {0}: As per the sequence of the operations in the work order {1}, submit the manufacturing entry for 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 manufactured_qty < current_operation_qty: + frappe.throw( + _( + "The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first." + ).format( + bold(self.get_qty_with_uom(current_operation_qty)), + bold(self.operation), + bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)), + bold(row.operation), + ), + OperationSequenceError, + ) +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) def validate_work_order(self): if self.is_work_order_closed(): @@ -1536,6 +1766,31 @@ class JobCard(Document): self.pending_qty = flt(kwargs.pending_qty) self.process_loss_qty = flt(kwargs.process_loss_qty) +<<<<<<< HEAD +======= + def validate_completion_qty_split(self, kwargs): + if not flt(kwargs.for_quantity): + return + + precision = self.precision("total_completed_qty") + accounted_qty = flt(kwargs.qty) + flt(kwargs.pending_qty) + flt(kwargs.process_loss_qty) + + if flt(accounted_qty, precision) == flt(kwargs.for_quantity, precision): + return + + frappe.throw( + _( + "Completed Quantity ({0}), Pending Quantity ({1}) and Process Loss Quantity ({2}) must add up to the Qty to Manufacture ({3})." + ).format( + bold(self.get_qty_with_uom(kwargs.qty)), + bold(self.get_qty_with_uom(kwargs.pending_qty)), + bold(self.get_qty_with_uom(kwargs.process_loss_qty)), + bold(self.get_qty_with_uom(kwargs.for_quantity)), + ) + ) + + def add_completion_time_logs(self, kwargs): +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) if kwargs.end_time: self.add_time_logs( to_time=kwargs.end_time, diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 826d558e830..f07ae2ea2b1 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -888,6 +888,85 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(wo_doc.process_loss_qty, 2) self.assertEqual(wo_doc.status, "Completed") +<<<<<<< HEAD +======= + def get_first_job_card(self, work_order): + return frappe.get_doc( + "Job Card", + frappe.get_all( + "Job Card", + filters={"work_order": work_order}, + order_by="sequence_id, creation", + limit=1, + pluck="name", + )[0], + ) + + def test_stock_uom_is_set_from_the_produced_item(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) + item_code = job_card.finished_good or job_card.production_item + + self.assertEqual(job_card.stock_uom, frappe.db.get_value("Item", item_code, "stock_uom")) + + def test_completion_qty_reduces_for_quantity_without_process_loss(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=3, + for_quantity=3, + pending_qty=0, + process_loss_qty=0, + end_time="2024-03-01 09:00:00", + ) + + job_card.reload() + self.assertEqual(flt(job_card.for_quantity), 3) + self.assertEqual(flt(job_card.total_completed_qty), 3) + self.assertEqual(flt(job_card.process_loss_qty), 0) + + def test_completion_qty_keeps_for_quantity_across_cycles(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-02 08:00:00"}) + job_card.save() + + job_card.complete_job_card( + qty=3, + for_quantity=5, + pending_qty=2, + process_loss_qty=0, + end_time="2024-03-02 09:00:00", + ) + + job_card.reload() + self.assertEqual(flt(job_card.for_quantity), 5) + self.assertEqual(flt(job_card.pending_qty), 2) + self.assertEqual(flt(job_card.process_loss_qty), 0) + + job_card.append("time_logs", {"from_time": "2024-03-02 10:00:00"}) + job_card.save() + + job_card.complete_job_card( + qty=2, + for_quantity=2, + pending_qty=0, + process_loss_qty=0, + end_time="2024-03-02 11:00:00", + ) + + job_card.reload() + self.assertEqual(flt(job_card.for_quantity), 5) + self.assertEqual(flt(job_card.total_completed_qty), 5) + self.assertEqual(flt(job_card.process_loss_qty), 0) + +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) def test_op_cost_calculation(self): from erpnext.manufacturing.doctype.routing.test_routing import ( create_routing, @@ -1879,3 +1958,101 @@ def create_semi_fg_bom(semi_fg_item, raw_item, inspection_required): bom.append("items", {"item_code": raw_item, "qty": 1}) bom.submit() return bom.name +<<<<<<< HEAD +======= + + +class TestJobCardLogic(ERPNextTestSuite): + """Field-level validations and pure quantity/capacity helpers, exercised on the + document directly so they don't need a Work Order / BOM (the integration suite does).""" + + def test_processing_a_submitted_or_cancelled_card_is_blocked(self): + submitted = frappe.new_doc("Job Card") + submitted.docstatus = 1 + self.assertRaises(frappe.ValidationError, submitted.validate_docstatus) + + cancelled = frappe.new_doc("Job Card") + cancelled.docstatus = 2 + self.assertRaises(frappe.ValidationError, cancelled.validate_docstatus) + + def test_complete_job_card_qty_guards(self): + 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(pending_qty=-1) + ) + self.assertRaises( + frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(process_loss_qty=-1) + ) + self.assertRaises( + frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=10) + ) + + def test_qty_in_messages_carries_the_uom(self): + jc = frappe.new_doc("Job Card") + jc.stock_uom = "Nos" + + self.assertEqual(jc.get_qty_with_uom(5), "5.0 Nos") + self.assertEqual(jc.get_qty_with_uom(0), "0.0 Nos") + + def test_completion_qty_split_must_add_up(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 5 + + # 3 completed + 2 pending + 0 lost == 5 to manufacture -> passes + jc.validate_complete_job_card_qty( + frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0) + ) + + self.assertRaises( + frappe.ValidationError, + jc.validate_complete_job_card_qty, + frappe._dict(for_quantity=3, qty=3, pending_qty=2, process_loss_qty=0), + ) + + def test_completed_qty_must_reconcile_with_for_quantity(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 10 + jc.total_completed_qty = 6 + jc.process_loss_qty = 0 + jc.pending_qty = 0 + # 6 + 0 + 0 != 10 -> throws + self.assertRaises(frappe.ValidationError, jc.validate_completed_qty_matches_for_quantity) + # completed + loss + pending == for_quantity -> passes + jc.pending_qty = 4 + jc.validate_completed_qty_matches_for_quantity() + + def test_set_process_loss(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 10 + jc.total_completed_qty = 6 + jc.pending_qty = 1 + jc.set_process_loss() + self.assertEqual(jc.process_loss_qty, 3) # 10 - 6 - 1 + + # no loss when nothing completed yet + 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) + + def test_capacity_overlap_detection(self): + jc = frappe.new_doc("Job Card") + sequential = [ + {"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"}, + {"from_time": "2026-01-01 11:00:00", "to_time": "2026-01-01 12:00:00"}, + ] + overlapping = [ + {"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"}, + {"from_time": "2026-01-01 10:30:00", "to_time": "2026-01-01 11:30:00"}, + ] + # sequential logs share one capacity slot; overlapping logs need two + self.assertEqual(len(jc.get_alloted_capacity(sequential)), 1) + self.assertEqual(len(jc.get_alloted_capacity(overlapping)), 2) + # capacity 1 overlaps with any log; capacity 2 only when both slots are taken + self.assertTrue(jc.has_overlap(1, sequential)) + self.assertFalse(jc.has_overlap(2, sequential)) + self.assertTrue(jc.has_overlap(2, overlapping)) +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) diff --git a/erpnext/manufacturing/page/shop_floor/shop_floor.py b/erpnext/manufacturing/page/shop_floor/shop_floor.py new file mode 100644 index 00000000000..0ef5b99613e --- /dev/null +++ b/erpnext/manufacturing/page/shop_floor/shop_floor.py @@ -0,0 +1,834 @@ +import frappe +from frappe import _ +from frappe.query_builder import Order +from frappe.query_builder.functions import Count, Date +from frappe.utils import cint, flt, get_datetime, getdate, now_datetime, time_diff_in_seconds +from pypika.terms import ExistsCriterion + +from erpnext.manufacturing.doctype.workstation.workstation import ( + get_status_color, + get_time_logs, +) +from erpnext.stock.doctype.quality_inspection_template.quality_inspection_template import ( + get_template_details, +) + +JOB_CARD_FIELDS = [ + "name", + "docstatus", + "production_item", + "work_order", + "operation", + "total_completed_qty", + "for_quantity", + "process_loss_qty", + "stock_uom", + "finished_good", + "transferred_qty", + "status", + "expected_start_date", + "expected_end_date", + "time_required", + "wip_warehouse", + "skip_material_transfer", + "backflush_from_wip_warehouse", + "is_paused", + "manufactured_qty", + "is_subcontracted", + "workstation", + "sequence_id", + "bom_no", + "operation_id", + "quality_inspection", + "quality_inspection_template", +] + +TODAY_SESSION_FIELDS = [ + "name", + "docstatus", + "production_item", + "finished_good", + "operation", + "total_completed_qty", + "for_quantity", + "process_loss_qty", + "total_time_in_mins", + "status", + "modified", +] + +# Roles that unlock the Shop Floor manager board (work-order overview). Anyone else gets the +# operator view. System Manager is included so admins always see the full picture. +MANAGER_ROLES = {"Shop Floor Manager", "Manufacturing Manager", "System Manager"} + +# Maps the manager buckets to the underlying Work Order statuses. "open" spans pending AND +# in-progress: starting a job card flips the Work Order to In Process, and separate tabs made +# it jump tabs on the next refresh — the operator would lose the card they were working on. +WORK_ORDER_STATUS_GROUPS = { + "open": ["In Process", "Not Started", "Submitted", "Stock Reserved", "Stock Partially Reserved"], + "completed": ["Completed"], +} + +WORK_ORDER_FIELDS = [ + "name", + "production_item", + "item_name", + "qty", + "produced_qty", + "status", + "planned_start_date", + "sales_order", + "bom_no", +] + + +@frappe.whitelist() +def submit_job_card(job_card: str): + """Submit a draft job card whose quantity has already been recorded via End Session.""" + frappe.has_permission("Job Card", "submit", throw=True) + jc = frappe.get_doc("Job Card", job_card) + if jc.docstatus == 0: + jc.submit() + return {"name": jc.name, "docstatus": jc.docstatus} + + +def _record_session(job_card, qty, for_quantity, pending_qty, process_loss_qty, end_time): + """Record the session qty + close the active time log via Job Card's complete_job_card. + + auto_submit=0 so the backend doesn't auto-create+submit a Manufacture Stock Entry — that's + a separate manual step driven by the post-submit prompt. Returns the reloaded doc. + """ + frappe.has_permission("Job Card", "write", throw=True) + + doc = frappe.get_doc("Job Card", job_card) + doc.run_method( + "complete_job_card", + qty=flt(qty), + for_quantity=flt(for_quantity), + pending_qty=flt(pending_qty), + process_loss_qty=flt(process_loss_qty), + end_time=end_time, + auto_submit=0, + ) + doc.reload() + return doc + + +@frappe.whitelist() +def save_and_continue( + job_card: str, + qty: float, + for_quantity: float, + pending_qty: float, + process_loss_qty: float, + end_time: str, +): + """Record the session qty + close the time log, then mark the JC as paused + so the MES keeps it in the active slot for the next session.""" + frappe.has_permission("Job Card", "write", throw=True) + doc = _record_session(job_card, qty, for_quantity, pending_qty, process_loss_qty, end_time) + if doc.docstatus == 0: + doc.db_set("is_paused", 1) + return {"name": doc.name} + + +@frappe.whitelist() +def complete_and_submit( + job_card: str, + qty: float, + for_quantity: float, + pending_qty: float, + process_loss_qty: float, + end_time: str, +): + """Record the session qty + close the time log + submit the JC.""" + frappe.has_permission("Job Card", "submit", throw=True) + doc = _record_session(job_card, qty, for_quantity, pending_qty, process_loss_qty, end_time) + if doc.docstatus == 0: + doc.submit() + return {"name": doc.name, "finished_good": doc.finished_good} + + +@frappe.whitelist() +def make_manufacture_stock_entry(job_card: str): + """Build a "Manufacture" Stock Entry for the finished goods and save as draft. + + Mirrors the Job Card form's "Make Stock Entry" button — uses the doc's own + make_stock_entry_for_semi_fg_item (purpose="Manufacture", job card linked) rather than + the generic make_stock_entry, which would produce a "Material Transfer for Manufacture". + Returns the draft SE name so the client can open it in a new tab. + """ + frappe.has_permission("Job Card", "read", throw=True) + frappe.has_permission("Stock Entry", "submit", throw=True) + doc = frappe.get_doc("Job Card", job_card) + se = doc.make_stock_entry_for_semi_fg_item(auto_submit=False) + return {"name": se.get("name")} + + +@frappe.whitelist() +def get_quality_inspection_checklist(job_card: str): + """Template parameters for the inline quality check an operator fills before submitting a + job card. Returns the resolved template, its parameter rows, any already-linked inspection, + and the item being inspected. + """ + frappe.has_permission("Job Card", "read", throw=True) + + jc = frappe.db.get_value( + "Job Card", + job_card, + [ + "quality_inspection", + "quality_inspection_template", + "operation", + "production_item", + "finished_good", + ], + as_dict=True, + ) + if not jc: + frappe.throw(_("Job Card {0} not found").format(job_card)) + + template = jc.quality_inspection_template + if not template and jc.operation: + template = frappe.get_cached_value("Operation", jc.operation, "quality_inspection_template") + + parameters = [] + for p in get_template_details(template): + parameters.append( + { + "specification": p.specification, + "value": p.value, + "numeric": cint(p.numeric), + "min_value": p.min_value, + "max_value": p.max_value, + "formula_based_criteria": cint(p.formula_based_criteria), + "acceptance_formula": p.acceptance_formula, + } + ) + + return { + "template": template, + "parameters": parameters, + "existing": jc.quality_inspection, + "item_code": jc.finished_good or jc.production_item, + } + + +@frappe.whitelist() +def submit_quality_inspection(job_card: str, readings: str | None = None): + """Create + submit an In-Process Quality Inspection for the job card and link it back, so the + standard Job Card.validate_inspection() gate passes when the card is submitted. + + `readings` is a JSON list of {specification, status, reading_value} captured inline. For numeric + / formula parameters the measured value is stored and the Quality Inspection auto-evaluates + pass/fail against min/max (or the formula); for qualitative parameters the operator's explicit + Accepted/Rejected is taken as authoritative (manual_inspection). The QI's own validation then + sets the overall Accepted/Rejected status. + """ + frappe.has_permission("Job Card", "write", throw=True) + frappe.has_permission("Quality Inspection", "submit", throw=True) + + jc = frappe.get_doc("Job Card", job_card) + + # Idempotent: if a submitted inspection is already linked, don't create another. + if jc.quality_inspection: + existing = frappe.db.get_value( + "Quality Inspection", jc.quality_inspection, ["status", "docstatus"], as_dict=True + ) + if existing and existing.docstatus == 1: + return {"name": jc.quality_inspection, "status": existing.status} + + template = jc.quality_inspection_template + if not template and jc.operation: + template = frappe.get_cached_value("Operation", jc.operation, "quality_inspection_template") + if not template: + frappe.throw(_("No Quality Inspection Template is configured for this operation.")) + + reading_map = {r.get("specification"): r for r in (frappe.parse_json(readings) or [])} + + qi = frappe.new_doc("Quality Inspection") + qi.inspection_type = "In Process" + qi.reference_type = "Job Card" + qi.reference_name = job_card + qi.item_code = jc.finished_good or jc.production_item + qi.bom_no = jc.bom_no + qi.quality_inspection_template = template + qi.inspected_by = frappe.session.user + qi.get_item_specification_details() # load readings from the template + + for reading in qi.readings: + entry = reading_map.get(reading.specification) + if not entry: + continue + value = entry.get("reading_value") + if reading.numeric or reading.formula_based_criteria: + # Measured value → let the Quality Inspection judge it against min/max or the formula. + if value not in (None, ""): + reading.reading_value = value + reading.reading_1 = value + else: + # Qualitative check → the operator's explicit pass/fail wins. + reading.manual_inspection = 1 + reading.status = entry.get("status") or "Accepted" + if value not in (None, ""): + reading.reading_value = value + + qi.insert() + qi.submit() # validate() → inspect_and_set_status() sets the overall Accepted/Rejected status + + # Link explicitly: the QI's own back-reference matches on production_item, which can differ + # from the operation's finished_good, so we set it directly to be safe. + jc.db_set("quality_inspection", qi.name) + return {"name": qi.name, "status": qi.status} + + +@frappe.whitelist() +def get_shop_floor_context(): + """Which experience to render — the manager board or the operator view — plus the + signed-in operator's Employee (so Start Job can pre-fill them).""" + roles = set(frappe.get_roles()) + can_manage = bool(roles & MANAGER_ROLES) + return { + "role_view": "manager" if can_manage else "operator", + "can_manage": can_manage, + "user_employee": frappe.db.get_value("Employee", {"user_id": frappe.session.user}, "name"), + } + + +@frappe.whitelist() +def get_work_orders( + status_group: str, + start: int = 0, + page_length: int = 20, + search: str | None = None, + with_job_cards_only: bool | int = 0, +): + """Paginated Work Orders for one manager bucket (open / completed), + each decorated with a job-card status breakdown for the card's progress chip. + + When `with_job_cards_only` is set, only Work Orders that have at least one (non-cancelled) + Job Card are returned — the board's opt-in "With job cards only" toggle. + """ + frappe.has_permission("Work Order", "read", throw=True) + + if status_group not in WORK_ORDER_STATUS_GROUPS: + frappe.throw(_("Invalid status group: {0}").format(status_group)) + + start = cint(start) + page_length = cint(page_length) or 20 + with_job_cards_only = cint(with_job_cards_only) + + # Active buckets show the oldest-planned first (work the floor next); completed shows newest first. + order = Order.desc if status_group == "completed" else Order.asc + + wo = frappe.qb.DocType("Work Order") + query = _apply_work_order_filters(frappe.qb.from_(wo), wo, status_group, search, with_job_cards_only) + work_orders = ( + query.select(*[wo[field] for field in WORK_ORDER_FIELDS]) + .orderby(wo.planned_start_date, order=order) + .limit(page_length) + .offset(start) + ).run(as_dict=True) + + total = _count_work_orders(status_group, search, with_job_cards_only) + + _enrich_work_orders(work_orders) + + return { + "work_orders": work_orders, + "total": cint(total), + "start": start, + "page_length": page_length, + } + + +def _has_job_cards_criterion(wo, docstatus): + jc = frappe.qb.DocType("Job Card") + return ExistsCriterion( + frappe.qb.from_(jc).select(jc.name).where((jc.work_order == wo.name) & (jc.docstatus == docstatus)) + ) + + +def _bucket_criterion(wo, status_group): + """The floor is done with a Work Order once every job card is submitted, even though the + Work Order stays "In Process" until the finished goods are received. Such operationally + complete orders belong on the Completed tab — not stranded under Pending / In Progress.""" + open_statuses = WORK_ORDER_STATUS_GROUPS["open"] + all_job_cards_done = _has_job_cards_criterion(wo, 1) & _has_job_cards_criterion(wo, 0).negate() + if status_group == "completed": + return (wo.status == "Completed") | (wo.status.isin(open_statuses) & all_job_cards_done) + return wo.status.isin(open_statuses) & ( + _has_job_cards_criterion(wo, 1).negate() | _has_job_cards_criterion(wo, 0) + ) + + +def _apply_work_order_filters(query, wo, status_group, search, with_job_cards_only): + """Shared WHERE clauses for the board's row + count queries (bucket, search, job-card toggle).""" + query = query.where((wo.docstatus == 1) & _bucket_criterion(wo, status_group)) + if search: + like = f"%{search}%" + query = query.where(wo.name.like(like) | wo.production_item.like(like) | wo.item_name.like(like)) + if with_job_cards_only: + jc = frappe.qb.DocType("Job Card") + wo_with_job_cards = frappe.qb.from_(jc).select(jc.work_order).where(jc.docstatus < 2) + query = query.where(wo.name.isin(wo_with_job_cards)) + return query + + +def _count_work_orders(status_group: str, search: str | None, with_job_cards_only: int = 0) -> int: + """Total Work Orders in a bucket (drives pagination), honouring the same filters as the rows.""" + wo = frappe.qb.DocType("Work Order") + query = _apply_work_order_filters(frappe.qb.from_(wo), wo, status_group, search, with_job_cards_only) + return cint(query.select(Count("*")).run()[0][0]) + + +def _enrich_work_orders(work_orders: list[dict]) -> None: + """Attach item/workstation image, status colour, % complete and a job-card breakdown to each row.""" + wo_names = [row.name for row in work_orders] + jc_counts = _get_job_card_status_counts(wo_names) + workstation_map = _get_current_workstation_map(wo_names) + for row in work_orders: + row.item_image = ( + frappe.get_cached_value("Item", row.production_item, "image") if row.production_item else None + ) + row.status_colour = get_status_color(row.status) + row.per_completed = round(flt(row.produced_qty) / flt(row.qty) * 100, 1) if flt(row.qty) else 0 + counts = jc_counts.get(row.name, {}) + row.job_card_status = counts.get("by_status", {}) + row.total_operations = counts.get("total", 0) + row.completed_operations = counts.get("completed", 0) + row.in_progress_operations = counts.get("in_progress", 0) + # Two segments for the card's progress bar: green (done) + orange (in progress); the rest + # of the track stays grey (pending / not started). + row.per_operations = ( + round(row.completed_operations / row.total_operations * 100, 1) if row.total_operations else 0 + ) + row.per_in_progress = ( + round(row.in_progress_operations / row.total_operations * 100, 1) if row.total_operations else 0 + ) + # Current/active operation's workstation (name + Active-Status image) for the card header. + workstation = workstation_map.get(row.name, {}) + row.workstation = workstation.get("workstation") + row.workstation_name = workstation.get("workstation_name") + row.workstation_image = workstation.get("image") + row.current_operation = workstation.get("operation") + + +def _get_current_workstation_map(wo_names: list[str]) -> dict[str, dict]: + """Map each Work Order to its current operation's workstation (name + Active-Status image). + + The "current" operation is the first not-yet-completed operation in the routing (by idx); + if every operation is complete, the last one is used so finished cards still show a workstation. + """ + if not wo_names: + return {} + + operations = frappe.get_all( + "Work Order Operation", + filters={"parent": ["in", wo_names]}, + fields=["parent", "idx", "operation", "status", "workstation"], + order_by="parent, idx", + ) + + ops_by_wo: dict[str, list] = {} + for op in operations: + ops_by_wo.setdefault(op.parent, []).append(op) + + chosen_by_wo = {} + workstations = set() + for wo_name, ops in ops_by_wo.items(): + current = next((op for op in ops if (op.status or "") != "Completed"), ops[-1]) + if current.workstation: + chosen_by_wo[wo_name] = current + workstations.add(current.workstation) + + ws_details = {} + if workstations: + for ws in frappe.get_all( + "Workstation", + filters={"name": ["in", list(workstations)]}, + fields=["name", "workstation_name", "on_status_image"], + ): + ws_details[ws.name] = ws + + result = {} + for wo_name, op in chosen_by_wo.items(): + detail = ws_details.get(op.workstation, {}) + result[wo_name] = { + "workstation": op.workstation, + "workstation_name": detail.get("workstation_name") or op.workstation, + "image": detail.get("on_status_image"), + "operation": op.operation, + } + return result + + +def _get_job_card_status_counts(wo_names: list[str]) -> dict[str, dict]: + """One batched query → {work_order: {by_status: {status: n}, total, completed}} for the WO cards.""" + if not wo_names: + return {} + + rows = frappe.get_all( + "Job Card", + filters={"work_order": ["in", wo_names], "docstatus": ["<", 2]}, + fields=["work_order", "status"], + ) + result: dict[str, dict] = {} + for row in rows: + entry = result.setdefault( + row.work_order, {"by_status": {}, "total": 0, "completed": 0, "in_progress": 0} + ) + status = "Not Started" if (row.status or "Open") == "Open" else row.status + entry["by_status"][status] = entry["by_status"].get(status, 0) + 1 + entry["total"] += 1 + # "To Manufacture" = operation done, only the Manufacture Stock Entry is pending — count it + # as completed so the work order's progress bar reflects the finished operation. + if status in ("Completed", "Submitted", "To Manufacture"): + entry["completed"] += 1 + elif status == "Work In Progress": + entry["in_progress"] += 1 + return result + + +@frappe.whitelist() +def get_data(workstation: str | None = None, work_order: str | None = None): + """ + Returns job-card data for the Shop Floor page. + + When `work_order` is set it wins over `workstation` and the result spans every + operation of that Work Order (including subcontracted job cards). When only + `workstation` is set, the result is the open + in-progress job cards for that + workstation, excluding subcontracted. + """ + if not (workstation or work_order): + return {"job_cards": [], "capacity": 1, "mode": None} + if not frappe.has_permission("Job Card", "read"): + return {"job_cards": [], "capacity": 1, "mode": None} + + filters, mode = _build_job_card_filters(workstation, work_order) + jc_data = _fetch_job_cards(filters, mode) + _enrich_job_cards(jc_data) + + capacity, oee = 1, None + if mode == "workstation": + capacity = frappe.db.get_value("Workstation", workstation, "production_capacity") or 1 + oee = get_workstation_oee(workstation) + + return { + "job_cards": jc_data, + "capacity": capacity, + "mode": mode, + "oee": oee, + "user_employee": frappe.db.get_value("Employee", {"user_id": frappe.session.user}, "name"), + "today_sessions": get_today_sessions(workstation, work_order), + } + + +def _build_job_card_filters(workstation, work_order): + """Filters + mode for the job-card query. work_order spans all ops; workstation is operator view.""" + filters = {"docstatus": ("<", 2)} + if work_order: + filters["work_order"] = work_order + return filters, "work_order" + + filters["workstation"] = workstation + filters["is_subcontracted"] = 0 + filters["status"] = ["!=", "Stopped"] + return filters, "workstation" + + +def _fetch_job_cards(filters, mode): + """Job cards matching filters. In workstation mode only drafts matter — submitted JCs are + done from MES's perspective and missed ones are picked up via the standard Job Card list. + + In work_order mode the whole routing is shown (incl. completed/submitted job cards), ordered + by the operation sequence so the operator reads them in manufacturing order. + """ + order_by = ( + "sequence_id asc, expected_start_date, expected_end_date" + if mode == "work_order" + else "expected_start_date, expected_end_date" + ) + # Drafts are the operator's working set; submitted "To Manufacture" cards are also kept so + # the station shows what still needs a Manufacture Stock Entry (its own section, client-side). + # This must be part of the query, not a post-filter: a busy workstation's history would + # otherwise fill the row limit with old submitted cards and hide the active drafts. + or_filters = [["docstatus", "=", 0], ["status", "=", "To Manufacture"]] if mode == "workstation" else None + return frappe.get_all( + "Job Card", + fields=JOB_CARD_FIELDS, + filters=filters, + or_filters=or_filters, + order_by=order_by, + limit=50, + ) + + +def _enrich_job_cards(jc_data): + """Decorate every row with display + material-availability data for the page.""" + job_card_names = [row.name for row in jc_data] + time_logs = get_time_logs(job_card_names) if job_card_names else {} + allow_excess_transfer = frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer") + for row in jc_data: + _enrich_job_card_row(row, time_logs, allow_excess_transfer) + + +def _enrich_job_card_row(row, time_logs, allow_excess_transfer): + """Attach status label, item image/uom, time logs and material availability to one row.""" + if row.status == "Open": + row.status = "Not Started" + + item_code = row.finished_good or row.production_item + row.fg_uom = frappe.get_cached_value("Item", item_code, "stock_uom") if item_code else None + row.item_image = frappe.get_cached_value("Item", item_code, "image") if item_code else None + row.status_colour = get_status_color(row.status) + row.time_logs = time_logs.get(row.name, []) + row.make_material_request = bool(row.for_quantity > row.transferred_qty or allow_excess_transfer) + # Required vs transferred + on-hand in source — operator sees shortages before starting work. + row.materials = get_job_card_materials(row.name) + # Guided execution: per-operation work instructions + quality-check state for the card. + row.instructions = _get_operation_instructions(row.operation) + row.qc = _get_job_card_qc(row) + + +def _get_operation_instructions(operation: str | None) -> dict | None: + """Description + rich Work Instructions from the Operation master, for the card's + Instructions panel. Returns None when the operation has neither, so the panel stays hidden. + + `work_instruction` is a Text Editor field (HTML) — Frappe bleach-sanitizes it on save, so it + is safe to render as-is on the client. `description` is plain text and must be escaped there. + """ + if not operation: + return None + + op = frappe.get_cached_value("Operation", operation, ["description", "work_instruction"], as_dict=True) + if not op: + return None + + description = (op.description or "").strip() + work_instruction = (op.work_instruction or "").strip() + if not description and not work_instruction: + return None + return {"description": description, "work_instruction": work_instruction} + + +def _get_job_card_qc(row) -> dict: + """Quality-check state for a job card row: whether an inspection is required before submit, + which template to use, and any inspection already linked (name + status + docstatus). + + "Required" mirrors Job Card.validate_inspection() — BOM inspection_required AND the Work Order + Operation's quality_inspection_required. When only a template is configured the check is + offered but not enforced. + """ + required = bool( + row.get("bom_no") + and frappe.get_cached_value("BOM", row.bom_no, "inspection_required") + and row.get("operation_id") + and frappe.db.get_value("Work Order Operation", row.operation_id, "quality_inspection_required") + ) + + template = row.get("quality_inspection_template") + if not template and row.get("operation"): + template = frappe.get_cached_value("Operation", row.operation, "quality_inspection_template") + + info = { + "required": required, + "template": template, + "has_checklist": bool(template), + "name": None, + "status": None, + "docstatus": None, + } + if row.get("quality_inspection"): + qi = frappe.db.get_value( + "Quality Inspection", row.quality_inspection, ["name", "status", "docstatus"], as_dict=True + ) + if qi: + info.update({"name": qi.name, "status": qi.status, "docstatus": qi.docstatus}) + return info + + +def get_job_card_materials(job_card: str) -> list[dict]: + """Required vs transferred + on-hand stock for each raw material in the source warehouse. + + Powers the active-job Materials side panel — operator sees shortages before starting work. + """ + items = frappe.get_all( + "Job Card Item", + filters={"parent": job_card}, + fields=["item_code", "item_name", "source_warehouse", "required_qty", "transferred_qty", "uom"], + order_by="idx", + ) + if not items: + return [] + + on_hand_map = _get_on_hand_map(items) + return [_build_material_row(it, on_hand_map) for it in items] + + +def _get_on_hand_map(items) -> dict[tuple[str, str], float]: + """Map (item_code, warehouse) → on-hand qty via batched Bin lookups.""" + pairs = {(it.item_code, it.source_warehouse) for it in items if it.source_warehouse} + if not pairs: + return {} + + bin_rows = frappe.get_all( + "Bin", + filters={ + "item_code": ["in", list({p[0] for p in pairs})], + "warehouse": ["in", list({p[1] for p in pairs})], + }, + fields=["item_code", "warehouse", "actual_qty"], + ) + return {(b.item_code, b.warehouse): flt(b.actual_qty) for b in bin_rows} + + +def _build_material_row(it, on_hand_map) -> dict: + """One material entry with shortage + status pill for the side panel.""" + required = flt(it.required_qty) + transferred = flt(it.transferred_qty) + on_hand = on_hand_map.get((it.item_code, it.source_warehouse), 0.0) + shortage = max(required - transferred, 0.0) + if transferred >= required: + status = "ready" + elif on_hand >= shortage: + status = "available" + else: + status = "short" + return { + "item_code": it.item_code, + "item_name": it.item_name or it.item_code, + "source_warehouse": it.source_warehouse, + "required_qty": required, + "transferred_qty": transferred, + "on_hand_qty": on_hand, + "shortage": shortage, + "uom": it.uom or "", + "status": status, + } + + +def get_today_sessions(workstation: str | None, work_order: str | None) -> list[dict]: + """Submitted job cards finalized today — used for the bottom 'Today's Sessions' strip. + + Filtered on docstatus=1 only (draft/cancelled excluded). The status pill follows the + job card's own status (e.g. Work In Progress → orange, Completed → green). + """ + filters = _today_sessions_filters(workstation, work_order) + if filters is None: + return [] + + rows = frappe.get_all( + "Job Card", + filters=filters, + fields=TODAY_SESSION_FIELDS, + order_by="modified desc", + limit=10, + ) + for r in rows: + item_code = r.finished_good or r.production_item + r.item_image = frappe.get_cached_value("Item", item_code, "image") if item_code else None + r.status_colour = get_status_color(r.status) + return rows + + +def _today_sessions_filters(workstation, work_order) -> dict | None: + """Submitted-today filter scoped to a work order or workstation; None if neither given. + + "To Manufacture" cards are excluded — they aren't finalized yet (Manufacture Stock Entry + pending) and get their own section, so they shouldn't appear among finished sessions. + """ + filters = { + "docstatus": 1, + "modified": [">=", get_datetime(f"{getdate()} 00:00:00")], + "status": ["!=", "To Manufacture"], + } + if work_order: + filters["work_order"] = work_order + elif workstation: + filters["workstation"] = workstation + else: + return None + return filters + + +def get_workstation_oee(workstation: str) -> dict | None: + """ + OEE = Availability X Performance X Quality, computed for today only. + + Caveat: without a downtime-reason capture step, Availability is just + (actual_run_time / scheduled_time) — it cannot distinguish planned breaks + from unplanned breakdowns. The number is directional, not audit-grade. + """ + today = getdate() + scheduled_min = flt(frappe.db.get_value("Workstation", workstation, "total_working_hours")) * 60 + actual_run_min, ideal_min = _get_run_and_ideal_minutes(workstation, today) + completed_jcs = _get_completed_jcs_today(workstation, today) + + # No activity at all today — nothing to display. + if actual_run_min == 0 and not completed_jcs: + return None + return _build_oee(scheduled_min, actual_run_min, ideal_min, completed_jcs) + + +def _get_run_and_ideal_minutes(workstation, today) -> tuple[float, float]: + """Sum actual run minutes (clipped to today) and the ideal minutes for produced qty.""" + today_start = get_datetime(f"{today} 00:00:00") + today_end = get_datetime(f"{today} 23:59:59") + now = now_datetime() + actual_run_min = 0.0 + ideal_min = 0.0 + for log in _get_oee_time_logs(workstation, today_start, today_end): + # Clip the log's interval to today's window for fair attribution. + start = max(get_datetime(log.from_time), today_start) + end = min(get_datetime(log.to_time) if log.to_time else now, today_end) + if end > start: + actual_run_min += time_diff_in_seconds(end, start) / 60 + if log.completed_qty and log.for_quantity and log.time_required: + ideal_min += (flt(log.time_required) / flt(log.for_quantity)) * flt(log.completed_qty) + return actual_run_min, ideal_min + + +def _get_oee_time_logs(workstation, today_start, today_end) -> list[dict]: + """Job Card time logs whose interval overlaps today's window.""" + tl = frappe.qb.DocType("Job Card Time Log") + jc = frappe.qb.DocType("Job Card") + return ( + frappe.qb.from_(tl) + .inner_join(jc) + .on(jc.name == tl.parent) + .select(tl.from_time, tl.to_time, tl.completed_qty, jc.for_quantity, jc.time_required) + .where(jc.workstation == workstation) + .where(jc.docstatus < 2) + .where(tl.from_time <= today_end) + .where(tl.to_time.isnull() | (tl.to_time >= today_start)) + ).run(as_dict=True) + + +def _get_completed_jcs_today(workstation, today) -> list[dict]: + """Job cards completed/submitted today — process loss is finalized at submission.""" + jc = frappe.qb.DocType("Job Card") + return ( + frappe.qb.from_(jc) + .select(jc.total_completed_qty, jc.process_loss_qty) + .where(jc.workstation == workstation) + .where(jc.status.isin(["Completed", "Submitted"])) + .where(Date(jc.modified) == today) + ).run(as_dict=True) + + +def _build_oee(scheduled_min, actual_run_min, ideal_min, completed_jcs) -> dict: + """Combine the three OEE factors into the response payload.""" + total_completed = sum(flt(j.total_completed_qty) for j in completed_jcs) + total_loss = sum(flt(j.process_loss_qty) for j in completed_jcs) + availability = min(actual_run_min / scheduled_min, 1.0) if scheduled_min > 0 else None + performance = min(ideal_min / actual_run_min, 1.0) if actual_run_min > 0 else 0.0 + quality = max(total_completed - total_loss, 0.0) / total_completed if total_completed > 0 else 1.0 + # OEE requires all three factors; without a schedule, Availability is unknown. + oee_val = round(availability * performance * quality * 100, 1) if availability is not None else None + return { + "oee": oee_val, + "availability": round(availability * 100, 1) if availability is not None else None, + "performance": round(performance * 100, 1), + "quality": round(quality * 100, 1), + } diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 1d893d4d8ae..0ec66c41087 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -496,4 +496,8 @@ erpnext.patches.v16_0.rename_ar_ap_ageing_filter erpnext.patches.v16_0.fix_subcontracting_titles erpnext.patches.v16_0.backfill_repost_accounting_ledger_status erpnext.patches.v16_0.merge_seeded_item_group_root +<<<<<<< HEAD erpnext.patches.v16_0.rename_italy_customer_name_fields +======= +erpnext.patches.v16_0.set_stock_uom_in_job_card +>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689)) diff --git a/erpnext/patches/v16_0/set_stock_uom_in_job_card.py b/erpnext/patches/v16_0/set_stock_uom_in_job_card.py new file mode 100644 index 00000000000..35abf69df05 --- /dev/null +++ b/erpnext/patches/v16_0/set_stock_uom_in_job_card.py @@ -0,0 +1,36 @@ +import frappe + + +def execute(): + job_cards = frappe.get_all( + "Job Card", + filters={"stock_uom": ("is", "not set")}, + fields=["name", "finished_good", "production_item"], + ) + + if not job_cards: + return + + item_codes = {code for row in job_cards if (code := row.finished_good or row.production_item)} + if not item_codes: + return + + stock_uoms = dict( + frappe.get_all( + "Item", + filters={"name": ("in", list(item_codes))}, + fields=["name", "stock_uom"], + as_list=True, + ) + ) + + updates = {} + for row in job_cards: + stock_uom = stock_uoms.get(row.finished_good or row.production_item) + if stock_uom: + updates[row.name] = {"stock_uom": stock_uom} + + if not updates: + return + + frappe.db.bulk_update("Job Card", updates) diff --git a/erpnext/public/js/shop_floor/shop_floor.js b/erpnext/public/js/shop_floor/shop_floor.js new file mode 100644 index 00000000000..a595102c004 --- /dev/null +++ b/erpnext/public/js/shop_floor/shop_floor.js @@ -0,0 +1,1758 @@ +// Shop Floor — an immersive, keyboard-first operator/manager interface. +// +// Two experiences share one app shell (see get_shop_floor_context on the server): +// • manager — a paginated board of work orders bucketed Pending / In Progress and Completed. +// Drilling into a work order opens its job cards in the operator pane. +// • operator — a focused workstation/work-order view to start, pause, complete and submit jobs. +// +// The whole surface is driveable from the keyboard (press ? for the cheat sheet) so an operator +// at a terminal never needs the mouse. + +// Job Card status → indicator colour, mirrored from workstation.get_status_color so the manager +// board can paint per-operation chips without a round-trip. +const JC_STATUS_COLORS = { + Completed: "green", + Submitted: "blue", + "Work In Progress": "orange", + "Material Transferred": "yellow", + "On Hold": "red", + Open: "gray", + "Not Started": "gray", +}; + +const MANAGER_BUCKETS = [ + { key: "open", label: __("Pending / In Progress"), dot: "orange" }, + { key: "completed", label: __("Completed"), dot: "green" }, +]; + +const PAGE_LENGTH = 20; + +class ShopFloor { + constructor({ wrapper }, page) { + this.wrapper = $(wrapper); + this.page = page; + this.timer_intervals = {}; + this.capacity = 1; + this.mode = null; + // Remembers each Materials panel's open/closed state (keyed by job card) so it + // survives re-renders — otherwise a reload right after a click resets the panel. + this.materials_open = {}; + // Same idea for the per-operation Work Instructions panel. + this.instructions_open = {}; + + // View state. + this.view = "operator"; // overwritten once context loads + this.active_bucket = "open"; + this.with_job_cards_only = true; // board default: hide WOs that have no job cards + this.buckets = {}; // key -> { rows, total, start, loaded } + this.selected_wo = null; + this.focus_index = -1; + this.op_state = { workstation: null, work_order: null }; + + this.make(); + this.bind_realtime(); + this.bind_lifecycle(); + this.init(); + } + + init() { + frappe.call("erpnext.manufacturing.page.shop_floor.shop_floor.get_shop_floor_context").then((r) => { + const ctx = r.message || {}; + this.view = ctx.role_view === "manager" ? "manager" : "operator"; + this.can_manage = !!ctx.can_manage; + this.user_employee = ctx.user_employee || null; + this.render_shell_controls(); + this.render_view(); + this.bind_keys(); + this.initialized = true; + this.apply_route_options(); + }); + } + + // ── App shell ──────────────────────────────────────────────────────────── + make() { + this.wrapper.append(` + ${this.styles()} +