feat: shop floor interface for operators (#55551)

* feat: shop floor interface for operators

* fix: documentation

* fix: UI/UX for shop floor

* fix: shop floor query and OEE edge cases from review

- Push the draft / To Manufacture condition into the Job Card query
  (or_filters) so a busy workstation's submitted history cannot fill
  the row limit and hide active drafts
- Clamp the OEE quality factor at zero when process loss exceeds
  completed qty

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rohitwaghchaure
2026-07-04 13:31:37 +05:30
committed by GitHub
parent 55c27d3dde
commit 8093e44746
21 changed files with 3740 additions and 635 deletions

View File

@@ -272,7 +272,7 @@
"fieldtype": "Select",
"label": "Status",
"no_copy": 1,
"options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted",
"options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nTo Manufacture\nCancelled\nCompleted",
"read_only": 1
},
{
@@ -695,7 +695,7 @@
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2026-06-19 17:39:42.293242",
"modified": "2026-06-20 17:39:42.293242",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",

View File

@@ -131,6 +131,7 @@ class JobCard(Document):
"Material Transferred",
"On Hold",
"Submitted",
"To Manufacture",
"Cancelled",
"Completed",
]
@@ -1302,8 +1303,13 @@ class JobCard(Document):
self.update_workstation_status()
def set_finished_good_status(self):
# Only reached for a submitted job card (docstatus == 1) with a finished good, see set_status().
if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity:
self.status = "Completed"
elif (self.total_completed_qty + self.process_loss_qty) >= self.for_quantity:
# 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"

View File

@@ -20,7 +20,8 @@
"sub_operations",
"total_operation_time",
"section_break_4",
"description"
"description",
"work_instruction"
],
"fields": [
{
@@ -43,6 +44,12 @@
"in_preview": 1,
"label": "Description"
},
{
"description": "Shown to operators on the Shop Floor. Supports rich text and embedded images for step-by-step guidance.",
"fieldname": "work_instruction",
"fieldtype": "Text Editor",
"label": "Work Instructions"
},
{
"collapsible": 1,
"fieldname": "sub_operations_section",

View File

@@ -25,6 +25,7 @@ class Operation(Document):
quality_inspection_template: DF.Link | None
sub_operations: DF.Table[SubOperation]
total_operation_time: DF.Float
work_instruction: DF.TextEditor | None
workstation: DF.Link | None
# end: auto-generated types

View File

@@ -203,6 +203,15 @@ frappe.ui.form.on("Work Order", {
}
}
let pending_ops = frm.doc?.operations?.filter((op) => op.completed_qty < frm.doc.qty);
// Jump to the operator Shop Floor view, pre-filtered to this work order.
if (frm.doc.docstatus === 1 && frm.doc.status !== "Closed" && pending_ops && pending_ops.length > 0) {
frm.add_custom_button(__("Operator Dashboard"), () => {
frappe.route_options = { work_order: frm.doc.name };
frappe.set_route("shop-floor");
});
}
if (frm.doc.status == "Completed") {
if (frm.doc.__onload.backflush_raw_materials_based_on == "Material Transferred for Manufacture") {
frm.add_custom_button(

View File

@@ -12,17 +12,14 @@ frappe.ui.form.on("Workstation", {
refresh(frm) {
frm.trigger("set_illustration_image");
frm.trigger("prepapre_dashboard");
},
prepapre_dashboard(frm) {
let $parent = $(frm.fields_dict["workstation_dashboard"].wrapper);
$parent.empty();
let workstation_dashboard = new WorkstationDashboard({
wrapper: $parent,
frm: frm,
});
if (!frm.is_new()) {
// Operator workflow now lives on the Shop Floor page; jump there filtered to this machine.
frm.add_custom_button(__("Shop Floor"), () => {
frappe.route_options = { workstation: frm.doc.name };
frappe.set_route("shop-floor");
});
}
},
onload(frm) {
@@ -80,533 +77,3 @@ frappe.tour["Workstation"] = [
),
},
];
class WorkstationDashboard {
constructor({ wrapper, frm }) {
this.$wrapper = $(wrapper);
this.frm = frm;
this.prepapre_dashboard();
}
prepapre_dashboard() {
frappe.call({
method: "erpnext.manufacturing.doctype.workstation.workstation.get_job_cards",
args: {
workstation: this.frm.doc.name,
},
callback: (r) => {
if (r.message) {
this.job_cards = r.message;
this.render_job_cards();
}
},
});
}
render_job_cards() {
this.template = frappe.render_template("workstation_job_card", {
data: this.job_cards,
});
this.timer_job_cards = {};
this.$wrapper.html(this.template);
this.setup_qrcode_fields();
this.prepare_timer();
this.setup_menu_actions();
this.toggle_job_card();
this.bind_events();
}
setup_qrcode_fields() {
this.start_job_qrcode = frappe.ui.form.make_control({
df: {
label: __("Start Job"),
fieldtype: "Data",
options: "Barcode",
placeholder: __("Scan Job Card Qrcode"),
},
parent: this.$wrapper.find(".qrcode-fields"),
render_input: true,
});
this.start_job_qrcode.$wrapper.addClass("form-column col-sm-6");
this.start_job_qrcode.$input.on("input", (e) => {
clearTimeout(this.start_job_qrcode_search);
this.start_job_qrcode_search = setTimeout(() => {
let job_card = this.start_job_qrcode.get_value();
if (job_card) {
this.validate_job_card(job_card, "Open", (job_card, qty) => {
this.start_job(job_card);
});
this.start_job_qrcode.set_value("");
}
}, 300);
});
this.complete_job_qrcode = frappe.ui.form.make_control({
df: {
label: __("Complete Job"),
fieldtype: "Data",
options: "Barcode",
placeholder: __("Scan Job Card Qrcode"),
},
parent: this.$wrapper.find(".qrcode-fields"),
render_input: true,
});
this.complete_job_qrcode.$input.on("input", (e) => {
clearTimeout(this.complete_job_qrcode_search);
this.complete_job_qrcode_search = setTimeout(() => {
let job_card = this.complete_job_qrcode.get_value();
if (job_card) {
this.validate_job_card(job_card, "Work In Progress", (job_card, qty) => {
this.complete_job(job_card, qty);
});
this.complete_job_qrcode.set_value("");
}
}, 300);
});
this.complete_job_qrcode.$wrapper.addClass("form-column col-sm-6");
}
validate_job_card(job_card, status, callback) {
frappe.call({
method: "erpnext.manufacturing.doctype.workstation.workstation.validate_job_card",
args: {
job_card: job_card,
status: status,
},
callback(r) {
callback(job_card, r.message);
},
});
}
setup_menu_actions() {
let me = this;
this.job_cards.forEach((data) => {
me.menu_btns = me.$wrapper.find(`.job-card-link[data-name='${data.name}']`);
$(me.menu_btns).find(".btn-resume").hide();
$(me.menu_btns).find(".btn-pause").hide();
$(me.menu_btns).find(".btn-complete .btn").attr("disabled", true);
if (
data.for_quantity + data.process_loss_qty > data.total_completed_qty &&
(data.skip_material_transfer ||
data.transferred_qty >= data.for_quantity + data.process_loss_qty ||
!data.finished_good)
) {
if (!data.time_logs?.length) {
$(me.menu_btns).find(".btn-start").show();
} else if (data.is_paused) {
$(me.menu_btns).find(".btn-start").hide();
$(me.menu_btns).find(".btn-resume").show();
} else if (data.for_quantity - data.manufactured_qty > 0) {
$(me.menu_btns).find(".btn-start").hide();
if (!data.is_paused) {
$(me.menu_btns).find(".btn-pause").show();
}
$(me.menu_btns).find(".btn-complete").show();
$(me.menu_btns).find(".btn-complete .btn").attr("disabled", false);
}
}
});
}
toggle_job_card() {
this.$wrapper.find(".collapse-indicator-job").on("click", (e) => {
$(e.currentTarget)
.closest(".form-dashboard-section")
.find(".section-body-job-card")
.toggleClass("hide");
if (
$(e.currentTarget)
.closest(".form-dashboard-section")
.find(".section-body-job-card")
.hasClass("hide")
)
$(e.currentTarget).html(frappe.utils.icon("chevron-down", "sm", "mb-1"));
else $(e.currentTarget).html(frappe.utils.icon("chevron-up", "sm", "mb-1"));
});
}
bind_events() {
let me = this;
this.$wrapper.find(".btn-transfer-materials").on("click", (e) => {
let job_card = $(e.currentTarget).closest("ul").attr("data-job-card");
this.make_material_request(job_card);
});
this.$wrapper.find(".btn-start").on("click", (e) => {
let job_card = $(e.currentTarget).closest("div").attr("data-job-card");
this.start_job(job_card);
});
this.$wrapper.find(".btn-pause").on("click", (e) => {
let job_card = $(e.currentTarget).closest("div").attr("data-job-card");
me.update_job_card(job_card, "pause_job", {
end_time: frappe.datetime.now_datetime(),
});
});
this.$wrapper.find(".btn-resume").on("click", (e) => {
let job_card = $(e.currentTarget).closest("div").attr("data-job-card");
me.update_job_card(job_card, "resume_job", {
start_time: frappe.datetime.now_datetime(),
});
});
this.$wrapper.find(".btn-complete").on("click", (e) => {
let job_card = $(e.currentTarget).closest("div").attr("data-job-card");
let for_quantity = $(e.currentTarget).attr("data-qty");
me.complete_job(job_card, for_quantity);
});
}
start_job(job_card) {
let me = this;
let fields = this.get_fields_for_employee();
this.employee_dialog = frappe.prompt(fields, (values) => {
me.update_job_card(job_card, "start_timer", values);
});
let default_employee = this.job_cards[0]?.user_employee;
if (default_employee) {
this.employee_dialog.fields_dict.employees.df.data.push({
employee: default_employee,
});
this.employee_dialog.fields_dict.employees.grid.refresh();
}
}
complete_job(job_card, for_quantity) {
frappe.prompt(
{
fieldname: "qty",
label: __("Completed Quantity"),
fieldtype: "Float",
reqd: 1,
default: flt(for_quantity || 0),
},
(data) => {
if (flt(data.qty) <= 0) {
frappe.throw(__("Quantity should be greater than 0"));
}
this.update_job_card(job_card, "complete_job_card", {
qty: flt(data.qty),
end_time: frappe.datetime.now_datetime(),
auto_submit: 1,
});
},
__("Enter Value"),
__("Submit")
);
}
get_fields_for_employee() {
let me = this;
return [
{
label: __("Start Time"),
fieldname: "start_time",
fieldtype: "Datetime",
default: frappe.datetime.now_datetime(),
},
{
label: __("Employee"),
fieldname: "employee",
fieldtype: "Link",
options: "Employee",
change() {
let employee = this.get_value();
let employees = me.employee_dialog.fields_dict.employees.df.data;
if (employee) {
let employee_exists = employees.find((d) => d.employee === employee);
if (!employee_exists) {
me.employee_dialog.fields_dict.employees.df.data.push({
employee: employee,
});
me.employee_dialog.fields_dict.employees.grid.refresh();
}
}
},
},
{ fieldtype: "Section Break" },
{
label: __("Employees"),
fieldname: "employees",
fieldtype: "Table",
data: [],
cannot_add_rows: 1,
cannot_delete_rows: 1,
fields: [
{
label: __("Employee"),
fieldname: "employee",
fieldtype: "Link",
options: "Employee",
in_list_view: 1,
},
],
},
];
}
update_job_card(job_card, method, data) {
let me = this;
frappe.call({
method: "erpnext.manufacturing.doctype.workstation.workstation.update_job_card",
args: {
job_card: job_card,
method: method,
start_time: data.start_time || "",
employees: data.employees || [],
end_time: data.end_time || "",
qty: data.qty || 0,
auto_submit: data.auto_submit || 0,
},
callback: () => {
$.each(me.timer_job_cards, (index, value) => {
clearInterval(value);
});
me.frm.reload_doc();
},
});
}
make_material_request(job_card) {
let me = this;
frappe.call({
method: "erpnext.manufacturing.doctype.workstation.workstation.get_raw_materials",
args: {
job_card: job_card,
},
callback: (r) => {
if (r.message) {
me.prepare_materials_modal(r.message, job_card, (job_card) => {
frappe.call({
method: "erpnext.manufacturing.doctype.job_card.mapper.make_stock_entry",
args: {
source_name: job_card,
},
callback: (r) => {
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
},
});
});
}
},
});
}
prepare_materials_modal(raw_materials, job_card, callback) {
let fields = this.get_raw_material_fields(raw_materials);
this.materials_dialog = new frappe.ui.Dialog({
title: "Raw Materials",
fields: fields,
size: "large",
primary_action_label: __("Make Transfer Entry"),
primary_action: () => {
this.materials_dialog.hide();
callback(job_card);
},
});
raw_materials.forEach((row) => {
this.materials_dialog.fields_dict.items.df.data.push(row);
});
this.materials_dialog.fields_dict.items.grid.refresh();
this.materials_dialog.show();
}
get_raw_material_fields(raw_materials) {
return [
{
label: __("Warehouse"),
fieldname: "warehouse",
fieldtype: "Link",
options: "Warehouse",
read_only: 1,
default: raw_materials[0].warehouse,
},
{ fieldtype: "Column Break" },
{
label: __("Skip Material Transfer"),
fieldname: "skip_material_transfer",
fieldtype: "Check",
read_only: 1,
default: raw_materials[0].skip_material_transfer,
},
{ fieldtype: "Section Break" },
{
label: __("Raw Materials"),
fieldname: "items",
fieldtype: "Table",
cannot_add_rows: 1,
cannot_delete_rows: 1,
data: [],
size: "extra-large",
fields: [
{
label: __("Item Code"),
fieldname: "item_code",
fieldtype: "Link",
options: "Item",
in_list_view: 1,
read_only: 1,
columns: 2,
},
{
label: __("UOM"),
fieldname: "uom",
fieldtype: "Link",
options: "UOM",
in_list_view: 1,
read_only: 1,
columns: 1,
},
{
label: __("Reqired Qty"),
fieldname: "required_qty",
fieldtype: "Float",
in_list_view: 1,
read_only: 1,
columns: 2,
},
{
label: __("Transferred Qty"),
fieldname: "transferred_qty",
fieldtype: "Float",
in_list_view: 1,
read_only: 1,
columns: 2,
},
{
label: __("Available Qty"),
fieldname: "stock_qty",
fieldtype: "Float",
in_list_view: 1,
read_only: 1,
columns: 2,
},
{
label: __("Available"),
fieldname: "material_availability_status",
fieldtype: "Check",
in_list_view: 1,
read_only: 1,
columns: 1,
},
],
},
];
}
prepare_timer() {
this.job_cards.forEach((data) => {
if (data.time_logs?.length) {
data._current_time = this.get_current_time(data);
if (data.time_logs[cint(data.time_logs.length) - 1].to_time || data.is_paused) {
this.updateStopwatch(data);
} else {
this.initialiseTimer(data);
}
}
});
}
update_job_card_details() {
let color_map = {
Pending: "var(--bg-blue)",
"In Process": "var(--bg-yellow)",
Submitted: "var(--bg-blue)",
Open: "var(--bg-gray)",
Closed: "var(--bg-green)",
"Work In Progress": "var(--bg-orange)",
};
this.job_cards.forEach((data) => {
let job_card_selector = this.$wrapper.find(`
[data-name='${data.name}']`);
$(job_card_selector).find(".job-card-status").text(data.status);
["blue", "gray", "green", "orange", "yellow"].forEach((color) => {
$(job_card_selector).find(".job-card-status").removeClass(color);
});
$(job_card_selector).find(".job-card-status").addClass(data.status_color);
$(job_card_selector).find(".job-card-status").css("backgroundColor", color_map[data.status]);
});
}
initialiseTimer(data) {
let timeout = setInterval(() => {
data._current_time += 1;
this.updateStopwatch(data);
}, 1000);
this.timer_job_cards[data.name] = timeout;
}
updateStopwatch(data) {
let increment = data._current_time;
let hours = Math.floor(increment / 3600);
let minutes = Math.floor((increment - hours * 3600) / 60);
let seconds = cint(increment - hours * 3600 - minutes * 60);
let job_card_selector = `[data-job-card='${data.name}']`;
let timer_selector = this.$wrapper.find(job_card_selector);
$(timer_selector)
.find(".hours")
.text(hours < 10 ? "0" + hours.toString() : hours.toString());
$(timer_selector)
.find(".minutes")
.text(minutes < 10 ? "0" + minutes.toString() : minutes.toString());
$(timer_selector)
.find(".seconds")
.text(seconds < 10 ? "0" + seconds.toString() : seconds.toString());
}
get_current_time(data) {
let current_time = 0.0;
data.time_logs.forEach((d) => {
if (d.to_time) {
if (d.time_in_mins) {
current_time += flt(d.time_in_mins, 2) * 60;
} else {
current_time += this.get_seconds_diff(d.to_time, d.from_time);
}
} else {
current_time += this.get_seconds_diff(frappe.datetime.now_datetime(), d.from_time);
}
});
return current_time;
}
get_seconds_diff(d1, d2) {
return moment(d1).diff(d2, "seconds");
}
}

View File

@@ -8,9 +8,6 @@
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"dashboard_tab",
"section_break_mqqv",
"workstation_dashboard",
"details_tab",
"workstation_name",
"workstation_type",
@@ -173,12 +170,6 @@
"fieldtype": "Float",
"label": "Total Working Hours"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "dashboard_tab",
"fieldtype": "Tab Break",
"label": "Job Cards"
},
{
"fieldname": "details_tab",
"fieldtype": "Tab Break",
@@ -190,16 +181,6 @@
"label": "Connections",
"show_dashboard": 1
},
{
"fieldname": "workstation_dashboard",
"fieldtype": "HTML",
"label": "Workstation Dashboard"
},
{
"fieldname": "section_break_mqqv",
"fieldtype": "Section Break",
"hide_border": 1
},
{
"default": "0",
"fieldname": "disabled",

View File

@@ -250,77 +250,6 @@ class Workstation(Document):
return doc
@frappe.whitelist()
def get_job_cards(workstation: str):
if frappe.has_permission("Job Card", "read"):
jc_data = frappe.get_all(
"Job Card",
fields=[
"name",
"production_item",
"work_order",
"operation",
"total_completed_qty",
"for_quantity",
"process_loss_qty",
"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",
],
filters={
"workstation": workstation,
"is_subcontracted": 0,
"docstatus": ("<", 2),
"status": ["not in", ["Completed", "Stopped"]],
},
order_by="expected_start_date, expected_end_date",
limit=10,
)
job_cards = [row.name for row in jc_data]
time_logs = get_time_logs(job_cards)
allow_excess_transfer = frappe.db.get_single_value(
"Manufacturing Settings", "job_card_excess_transfer"
)
user_employee = frappe.db.get_value("Employee", {"user_id": frappe.session.user}, "name")
for row in jc_data:
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")
row.status_colour = get_status_color(row.status)
row.job_card_link = f"""
<a class="ellipsis" data-doctype="Job Card" data-name="{row.name}" href="/app/job-card/{row.name}" title="" data-original-title="{row.name}">{row.name}</a>
"""
row.operation_link = f"""
<a class="ellipsis" data-doctype="Operation" data-name="{row.operation}" href="/app/operation/{row.operation}" title="" data-original-title="{row.operation}">{row.operation}</a>
"""
row.work_order_link = get_link_to_form("Work Order", row.work_order)
row.time_logs = time_logs.get(row.name, [])
row.make_material_request = False
if row.for_quantity > row.transferred_qty or allow_excess_transfer:
row.make_material_request = True
row.user_employee = user_employee
return jc_data
def get_status_color(status):
color_map = {
"Pending": "blue",
@@ -328,7 +257,9 @@ def get_status_color(status):
"Submitted": "blue",
"Open": "gray",
"Closed": "green",
"Completed": "green",
"Work In Progress": "orange",
"To Manufacture": "purple",
}
return color_map.get(status, "blue")

View File

@@ -0,0 +1,26 @@
frappe.pages["shop-floor"].on_page_load = function (wrapper) {
const page = frappe.ui.make_app_page({
parent: wrapper,
title: __("Shop Floor"),
single_column: true,
// Kiosk feel — drop the desk sidebar so the floor view owns the screen.
hide_sidebar: true,
});
frappe.shop_floor = new frappe.ui.ShopFloor(
{ wrapper: $(wrapper).find(".layout-main-section") },
wrapper.page
);
};
// Pick up filters passed in via frappe.route_options (e.g. the "Shop Floor" button on Work Order)
// and switch the body into immersive (full-screen) mode while the page is shown.
// on_page_show fires on every navigation, so it also works when the page is already cached.
// (Frappe has no on_page_hide hook — the class itself drops the body class + keyboard binding
// on the next route change, see ShopFloor.bind_lifecycle.)
frappe.pages["shop-floor"].on_page_show = function () {
$(document.body).addClass("shop-floor-active");
if (frappe.shop_floor && frappe.shop_floor.on_show) {
frappe.shop_floor.on_show();
}
};

View File

@@ -0,0 +1,32 @@
{
"content": null,
"creation": "2026-05-30 10:00:00",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-06-05 00:24:12.148085",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "shop-floor",
"owner": "Administrator",
"page_name": "shop-floor",
"roles": [
{
"role": "Manufacturing User"
},
{
"role": "Manufacturing Manager"
},
{
"role": "Shop Floor Manager"
},
{
"role": "Shop Floor User"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Shop Floor"
}

View File

@@ -0,0 +1,812 @@
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 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",
"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 three manager buckets to the underlying Work Order statuses.
WORK_ORDER_STATUS_GROUPS = {
"in_progress": ["In Process"],
"pending": ["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 (in_progress / pending / 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)
statuses = WORK_ORDER_STATUS_GROUPS.get(status_group)
if not statuses:
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, statuses, 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(statuses, 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 _apply_work_order_filters(query, wo, statuses, 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) & (wo.status.isin(statuses)))
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(statuses: list[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, statuses, 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),
}

View File

@@ -493,3 +493,4 @@ erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb
erpnext.patches.v16_0.set_default_close_opportunity_after_days
execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
erpnext.patches.v16_0.create_shop_floor_roles

View File

@@ -0,0 +1,5 @@
from erpnext.setup.install import create_shop_floor_roles
def execute():
create_shop_floor_roles()

View File

@@ -9,6 +9,8 @@ import "./utils/serial_no_batch_selector";
import "./payment/payments";
import "./templates/visual_plant_floor_template.html";
import "./plant_floor_visual/visual_plant";
import "./templates/shop_floor_template.html";
import "./shop_floor/shop_floor";
import "./controllers/taxes_and_totals";
import "./controllers/transaction";
import "./templates/item_selector.html";

View File

@@ -145,6 +145,14 @@ class VisualPlantFloor {
});
$(template).appendTo(this.wrapper.find(".plant-floor-container"));
this.wrapper.find(".workstation-image").on("click", (e) => {
let workstation_name = $(e.currentTarget)
.closest(".workstation-wrapper")
.attr("data-workstation");
frappe.route_options = { workstation: workstation_name };
frappe.set_route("shop-floor");
});
}
prepare_menu() {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
<span class="workstation-status-title" style="font-size:10px">{{row.status}}</span>
</span>
</div>
<div class="workstation-image {{row.workstation_off}}" onclick="location.href='{{ frappe.utils.get_form_link('Workstation', row.name)}}'">
<div class="workstation-image {{row.workstation_off}}">
<div class="workstation-image-container flex items-center justify-center h-32 border-b-grey text-6xl text-grey-100">
{% if(row.status_image) { %}
<img class="workstation-image-cls" src="{{row.status_image}}">

View File

@@ -21,6 +21,8 @@ def after_install():
if not frappe.db.exists("Role", "Analytics"):
frappe.get_doc({"doctype": "Role", "role_name": "Analytics"}).insert()
create_shop_floor_roles()
set_single_defaults()
setup_repost_defaults()
create_print_setting_custom_fields()
@@ -50,6 +52,15 @@ def make_default_operations():
doc.insert(ignore_permissions=True)
def create_shop_floor_roles():
"""Roles that drive the Shop Floor page's two experiences (manager board vs operator view)."""
for role_name in ("Shop Floor Manager", "Shop Floor User"):
if not frappe.db.exists("Role", role_name):
frappe.get_doc({"doctype": "Role", "role_name": role_name, "desk_access": 1}).insert(
ignore_permissions=True
)
def set_single_defaults():
for dt in (
"Accounts Settings",

View File

@@ -15,6 +15,7 @@
"label": "Home",
"link_to": "Manufacturing",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
@@ -27,42 +28,49 @@
"label": "Dashboard",
"link_to": "Manufacturing",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "list-tree",
"indent": 0,
"keep_closed": 0,
"label": "BOM",
"link_to": "BOM",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "factory",
"indent": 0,
"keep_closed": 0,
"label": "Work Order",
"link_to": "Work Order",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "person-standing",
"indent": 0,
"keep_closed": 0,
"label": "Job Card",
"link_to": "Job Card",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
@@ -75,85 +83,112 @@
"label": "Stock Entry",
"link_to": "Stock Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"default_workspace": 0,
"icon": "hammer",
"indent": 0,
"keep_closed": 0,
"label": "Shop Floor",
"link_to": "shop-floor",
"link_type": "Page",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "rocket",
"indent": 1,
"keep_closed": 1,
"label": "Material Planning",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Item Lead Time",
"link_to": "Item Lead Time",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Plan",
"link_to": "Production Plan",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Forecasting",
"link_to": "Exponential Smoothing Forecasting",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Master Production Schedule",
"link_to": "Master Production Schedule",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Sales Forecast",
"link_to": "Sales Forecast",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Planning Report",
"link_to": "Production Planning Report",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
@@ -165,268 +200,315 @@
"keep_closed": 1,
"label": "Tools",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Creator",
"link_to": "BOM Creator",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Update Tool",
"link_to": "BOM Update Tool",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Comparison Tool",
"link_to": "bom-comparison-tool",
"link_type": "Page",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Downtime Entry",
"link_to": "Downtime Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "notepad-text",
"indent": 1,
"keep_closed": 1,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Planning Report",
"link_to": "Production Planning Report",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Work Order Summary",
"link_to": "Work Order Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Quality Inspection Summary",
"link_to": "Quality Inspection Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Downtime Analysis",
"link_to": "Downtime Analysis",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Job Card Summary",
"link_to": "Job Card Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Search",
"link_to": "BOM Search",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Analytics",
"link_to": "Production Analytics",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Operations Time",
"link_to": "BOM Operations Time",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Work Order Consumed Materials",
"link_to": "Work Order Consumed Materials",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Item",
"link_to": "Item",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Warehouse",
"link_to": "Warehouse",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Operation",
"link_to": "Operation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Workstation",
"link_to": "Workstation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Workstation Type",
"link_to": "Workstation Type",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Plant Floor",
"link_to": "Plant Floor",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Routing",
"link_to": "Routing",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "settings",
"indent": 0,
"keep_closed": 0,
"label": "Settings",
"link_to": "Manufacturing Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"modified": "2026-07-03 11:01:50.260118",
"modified": "2026-07-03 23:08:12.319618",
"modified_by": "Administrator",
"module": "Manufacturing",
"module_onboarding": "Manufacturing Onboarding",