From 569f82f0e596c7be3f4b7bf2ac312a0cb374f093 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Fri, 19 Jun 2026 17:42:17 +0530 Subject: [PATCH] fix: add partially transferred status and fix button visibility for partial material transfer on job card (cherry picked from commit a22b83a97fe1dcf957d3d9b8bba7240c63640a82) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.js # erpnext/manufacturing/doctype/job_card/job_card.json # erpnext/manufacturing/doctype/job_card/job_card.py --- .../doctype/job_card/job_card.js | 389 ++++++++++++++++++ .../doctype/job_card/job_card.json | 6 +- .../doctype/job_card/job_card.py | 79 ++++ .../doctype/job_card/job_card_list.js | 1 + 4 files changed, 474 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index e096c73cc61..afa3dc61da5 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -167,8 +167,238 @@ frappe.ui.form.on("Job Card", { } }, +<<<<<<< HEAD setup_quality_inspection: function (frm) { let quality_inspection_field = frm.get_docfield("quality_inspection"); +======= + // Adds Material Request and Material Transfer buttons when items need to be transferred. + setup_material_transfer_buttons(frm, has_items) { + const { doc } = frm; + + if (frm.is_new() || doc.skip_material_transfer || doc.docstatus >= 2) return; + + const excess_transfer_allowed = doc.__onload.job_card_excess_transfer; + const to_transfer = + has_items && doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty)); + const to_request = to_transfer; + + if (has_items && (to_request || excess_transfer_allowed)) { + frm.add_custom_button( + __("Material Request"), + () => frm.trigger("make_material_request"), + __("Create") + ); + } + + if (has_items && (to_transfer || excess_transfer_allowed)) { + frm.add_custom_button( + __("Material Transfer"), + () => frm.trigger("make_stock_entry"), + __("Create") + ); + } + }, + + // Renders the dashboard widget (info + timer + action buttons) into job_card_dashboard wrapper. + // Returns true if the job timer is actively running, so the caller can skip the stock entry button. + setup_job_action_buttons(frm, has_items) { + return frm.events.make_dashboard(frm, has_items); + }, + + complete_job_card(frm) { + let pending_qty = frm.doc.for_quantity - frm.doc.total_completed_qty; + if (frm.doc.pending_qty > 0) { + pending_qty = frm.doc.pending_qty; + } + + const fields = [ + { + fieldtype: "Float", + label: __("Qty to Manufacture"), + fieldname: "for_quantity", + reqd: 1, + default: pending_qty, + change() { + const dialog = frm.job_completion_dialog; + dialog.set_value("completed_qty", dialog.get_value("for_quantity")); + dialog.set_value("process_loss_qty", 0); + }, + }, + { + fieldtype: "Float", + label: __("Completed Quantity"), + fieldname: "completed_qty", + reqd: 1, + default: pending_qty, + change() { + const dialog = frm.job_completion_dialog; + const remaining = dialog.get_value("for_quantity") - dialog.get_value("completed_qty"); + if (remaining > 0 && remaining != dialog.get_value("pending_qty")) { + dialog.set_value("pending_qty", remaining); + } + }, + }, + { + fieldtype: "Float", + label: __("Pending Quantity"), + fieldname: "pending_qty", + default: 0.0, + change() { + const dialog = frm.job_completion_dialog; + const process_loss_qty = + dialog.get_value("for_quantity") - + dialog.get_value("completed_qty") - + dialog.get_value("pending_qty"); + if (process_loss_qty >= 0 && process_loss_qty != dialog.get_value("process_loss_qty")) { + dialog.set_value("process_loss_qty", process_loss_qty); + } + }, + }, + { + fieldtype: "Float", + label: __("Process Loss Quantity"), + fieldname: "process_loss_qty", + onchange() { + const dialog = frm.job_completion_dialog; + const remaining = + dialog.get_value("for_quantity") - + dialog.get_value("completed_qty") - + dialog.get_value("process_loss_qty"); + if (remaining >= 0 && remaining != dialog.get_value("pending_qty")) { + dialog.set_value("pending_qty", remaining); + } + }, + }, + { + fieldtype: "Section Break", + }, + ]; + + if (frm.doc.sub_operations?.length) { + fields.push({ + fieldtype: "Link", + label: __("Sub Operation"), + fieldname: "sub_operation", + options: "Operation", + get_query() { + const non_completed = frm.doc.sub_operations.filter((d) => d.status === "Pending"); + return { + filters: { name: ["in", non_completed.map((d) => d.sub_operation)] }, + }; + }, + reqd: 1, + }); + } + + const last_completed_row = get_last_completed_row(frm.doc.time_logs); + let last_row = {}; + if (frm.doc.sub_operations?.length && frm.doc.time_logs?.length) { + last_row = get_last_row(frm.doc.time_logs); + } + + if (!last_completed_row || !last_completed_row.to_time || !last_row.to_time) { + fields.push({ + fieldtype: "Datetime", + label: __("End Time"), + fieldname: "end_time", + default: frappe.datetime.now_datetime(), + }); + } + + frm.job_completion_dialog = frappe.prompt( + fields, + (data) => { + if (data.qty <= 0) { + frappe.throw(__("Quantity should be greater than 0")); + } + + frm.call({ + method: "complete_job_card", + doc: frm.doc, + args: { + qty: data.completed_qty, + for_quantity: data.for_quantity, + pending_qty: data.pending_qty, + process_loss_qty: data.process_loss_qty, + end_time: data.end_time, + sub_operation: data.sub_operation, + }, + callback() { + frm.reload_doc(); + }, + }); + }, + __("Enter Value"), + __("Update"), + __("Set Finished Good Quantity") + ); + }, + + make_subcontracting_po(frm) { + if (frm.doc.docstatus === 1 && frm.doc.for_quantity > frm.doc.manufactured_qty) { + frm.add_custom_button(__("Make Subcontracting PO"), () => { + frappe.model.open_mapped_doc({ + method: "erpnext.manufacturing.doctype.job_card.mapper.make_subcontracting_po", + frm: frm, + }); + }).addClass("btn-primary"); + } + }, + + start_timer(frm, start_time, employees) { + frm.call({ + method: "start_timer", + doc: frm.doc, + args: { start_time, employees }, + callback() { + frm.reload_doc(); + }, + }); + }, + + make_finished_good(frm) { + const fields = [ + { + fieldtype: "Float", + label: __("Completed Quantity"), + fieldname: "qty", + reqd: 1, + default: frm.doc.for_quantity - frm.doc.manufactured_qty, + }, + { + fieldtype: "Datetime", + label: __("End Time"), + fieldname: "end_time", + default: frappe.datetime.now_datetime(), + }, + ]; + + frappe.prompt( + fields, + (data) => { + if (data.qty <= 0) { + frappe.throw(__("Quantity should be greater than 0")); + } + + frm.call({ + method: "make_finished_good", + doc: frm.doc, + args: { qty: data.qty, end_time: data.end_time }, + callback(r) { + const doc = frappe.model.sync(r.message); + frappe.set_route("Form", doc[0].doctype, doc[0].name); + }, + }); + }, + __("Enter Value"), + __("Update"), + __("Set Finished Good Quantity") + ); + }, + + setup_quality_inspection(frm) { + const quality_inspection_field = frm.get_docfield("quality_inspection"); +>>>>>>> a22b83a97f (fix: add partially transferred status and fix button visibility for partial material transfer on job card) quality_inspection_field.get_route_options_for_new_doc = function (frm) { return { inspection_type: "In Process", @@ -298,9 +528,168 @@ frappe.ui.form.on("Job Card", { prepare_timer_buttons: function (frm) { frm.trigger("make_dashboard"); +<<<<<<< HEAD if (!frm.doc.started_time && !frm.doc.current_time) { frm.add_custom_button(__("Start Job"), () => { if ((frm.doc.employee && !frm.doc.employee.length) || !frm.doc.employee) { +======= + frappe.call({ + method: "erpnext.manufacturing.doctype.job_card.job_card.make_time_log", + args: { args }, + freeze: true, + callback() { + frm.reload_doc(); + frm.trigger("make_dashboard"); + }, + }); + }, + + update_sub_operation(frm, args) { + if (frm.doc.sub_operations?.length) { + const pending_sub_ops = frm.doc.sub_operations.filter((d) => d.status != "Complete"); + if (pending_sub_ops.length) { + args["sub_operation"] = pending_sub_ops[0].sub_operation; + } + } + }, + + make_dashboard(frm, has_items) { + if (frm.doc.__islocal) return false; + + frm.dashboard.refresh(); + + // Clear any previously running timer tick before re-rendering. + if (frm._jcd_timer_interval) { + clearInterval(frm._jcd_timer_interval); + frm._jcd_timer_interval = null; + } + + const wrapper = $(frm.fields_dict["job_card_dashboard"].wrapper); + wrapper.empty(); + + if (frm.doc.docstatus !== 0) { + return; + } + + const { doc } = frm; + const { time_logs, status } = doc; + + // ── Determine which action buttons to show ──────────────────────── + const has_remaining_qty = doc.for_quantity + doc.process_loss_qty > doc.total_completed_qty; + const pending_transfer = + has_items && doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty)); + const materials_ready = + doc.skip_material_transfer || !pending_transfer || !doc.finished_good || !has_items; + + let last_row = {}; + const has_sub_ops_or_pending_qty = doc.sub_operations?.length || doc.pending_qty > 0; + if (has_sub_ops_or_pending_qty && time_logs?.length) { + last_row = get_last_row(time_logs); + } + + const no_time_logs_yet = !time_logs?.length; + const pending_qty_cycle_done = flt(doc.pending_qty) > 0.0 && last_row?.to_time; + const sub_operation_cycle_done = doc.sub_operations?.length && last_row?.to_time; + const should_show_start = + (no_time_logs_yet || pending_qty_cycle_done || sub_operation_cycle_done) && !doc.is_paused; + + const last_log_complete = time_logs?.length && time_logs[time_logs.length - 1].to_time; + const is_on_hold = status === "On Hold"; + const is_actively_running = !!( + time_logs?.length && + !last_log_complete && + !is_on_hold && + !doc.is_paused + ); + + let show_start = false, + show_pause = false, + show_resume = false, + show_complete = false, + is_timer_running = false; + + if (has_remaining_qty && materials_ready) { + const manufactured_qty = doc.manufactured_qty || doc.total_completed_qty; + const qty_yet_to_manufacture = doc.for_quantity - (manufactured_qty + doc.process_loss_qty); + + if (should_show_start) { + show_start = true; + } else if (doc.is_paused) { + show_resume = true; + } else if (qty_yet_to_manufacture > 0) { + show_pause = true; + show_complete = true; + is_timer_running = true; + } + } + + // ── Timer color reflects job state ──────────────────────────────── + const [timer_color, timer_bg, timer_border] = [ + "var(--gray-600,#6b7280)", + "var(--gray-100,#f3f4f6)", + "var(--gray-300,#d1d5db)", + ]; + + // ── Action button HTML ──────────────────────────────────────────── + const btn = (cls, icon_path, label, icon_color) => ` + `; + + const icons = { + play: { d: '', fill: "currentColor", stroke: "none" }, + pause: { + d: '', + fill: "currentColor", + stroke: "none", + }, + check: { d: '', sw: 3 }, + }; + + const buttons_html = [ + show_start && btn("btn-primary jcd-btn-start", "play", __("Start Job")), + show_resume && btn("btn-primary jcd-btn-resume", "play", __("Resume Job")), + show_pause && btn("btn-default jcd-btn-pause", "pause", __("Pause Job")), + show_complete && btn("btn-primary jcd-btn-complete", "check", __("Complete Job"), "white"), + ] + .filter(Boolean) + .join(""); + + // ── Render widget ───────────────────────────────────────────────── + wrapper.append(` +
+
+
+
+ ${__("Elapsed Time")} +
+
+ ${frappe.utils.icon("clock-4", "md", "", "", "", "", timer_color)} + + 00:00:00 + +
+
+
+ ${buttons_html} +
+
+
`); + + // ── Wire up button click handlers ───────────────────────────────── + if (show_start) { + wrapper.find(".jcd-btn-start").on("click", () => { + const from_time = frappe.datetime.now_datetime(); + const has_no_employee = !frm.doc.employee || !frm.doc.employee.length; + + if (has_no_employee) { +>>>>>>> a22b83a97f (fix: add partially transferred status and fix button visibility for partial material transfer on job card) frappe.prompt( { fieldtype: "Table MultiSelect", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index ba680df99f9..e312b50349a 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -234,7 +234,7 @@ "fieldtype": "Select", "label": "Status", "no_copy": 1, - "options": "Open\nWork In Progress\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted", + "options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted", "read_only": 1 }, { @@ -513,7 +513,11 @@ ], "is_submittable": 1, "links": [], +<<<<<<< HEAD "modified": "2026-05-12 12:17:17.750857", +======= + "modified": "2026-06-19 17:39:42.293242", +>>>>>>> a22b83a97f (fix: add partially transferred status and fix button visibility for partial material transfer on job card) "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 ae60bcb1ca8..87e79c05920 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -107,6 +107,7 @@ class JobCard(Document): status: DF.Literal[ "Open", "Work In Progress", + "Partially Transferred", "Material Transferred", "On Hold", "Submitted", @@ -927,6 +928,48 @@ class JobCard(Document): frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty)) +<<<<<<< HEAD +======= + self.set_status(update_status=True) + + 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(required_qty), frappe.bold(row.item_code), ste_doc.job_card), + title=_("Excess Transfer"), + exc=JobCardOverTransferError, + ) + +>>>>>>> a22b83a97f (fix: add partially transferred status and fix button visibility for partial material transfer on job card) def set_transferred_qty(self, update_status=False): "Set total FG Qty in Job Card for which RM was transferred." if not self.items: @@ -1000,7 +1043,42 @@ class JobCard(Document): "Work In Progress": "Production", }.get(self.status) +<<<<<<< HEAD self.update_status_in_workstation(status) +======= + def set_finished_good_status(self): + if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity: + self.status = "Completed" + 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.for_quantity <= (self.total_completed_qty + self.process_loss_qty) or not self.items + ): + self.status = "Completed" +>>>>>>> a22b83a97f (fix: add partially transferred status and fix button visibility for partial material transfer on job card) def set_wip_warehouse(self): if not self.wip_warehouse: @@ -1230,6 +1308,7 @@ def get_job_details(start, end, filters=None): event_color = { "Completed": "#cdf5a6", "Material Transferred": "#ffdd9e", + "Partially Transferred": "#ffe5b4", "Work In Progress": "#D3D3D3", } diff --git a/erpnext/manufacturing/doctype/job_card/job_card_list.js b/erpnext/manufacturing/doctype/job_card/job_card_list.js index e417b7f576d..bddb11d00bb 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card_list.js +++ b/erpnext/manufacturing/doctype/job_card/job_card_list.js @@ -7,6 +7,7 @@ frappe.listview_settings["Job Card"] = { Completed: "green", Cancelled: "red", "Material Transferred": "blue", + "Partially Transferred": "yellow", Open: "red", }; const status = doc.status || "Open";