mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-19 01:18:43 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
0
erpnext/manufacturing/page/shop_floor/__init__.py
Normal file
0
erpnext/manufacturing/page/shop_floor/__init__.py
Normal file
26
erpnext/manufacturing/page/shop_floor/shop_floor.js
Normal file
26
erpnext/manufacturing/page/shop_floor/shop_floor.js
Normal 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();
|
||||
}
|
||||
};
|
||||
32
erpnext/manufacturing/page/shop_floor/shop_floor.json
Normal file
32
erpnext/manufacturing/page/shop_floor/shop_floor.json
Normal 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"
|
||||
}
|
||||
812
erpnext/manufacturing/page/shop_floor/shop_floor.py
Normal file
812
erpnext/manufacturing/page/shop_floor/shop_floor.py
Normal 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),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
5
erpnext/patches/v16_0/create_shop_floor_roles.py
Normal file
5
erpnext/patches/v16_0/create_shop_floor_roles.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from erpnext.setup.install import create_shop_floor_roles
|
||||
|
||||
|
||||
def execute():
|
||||
create_shop_floor_roles()
|
||||
@@ -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";
|
||||
|
||||
@@ -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() {
|
||||
|
||||
1665
erpnext/public/js/shop_floor/shop_floor.js
Normal file
1665
erpnext/public/js/shop_floor/shop_floor.js
Normal file
File diff suppressed because it is too large
Load Diff
1059
erpnext/public/js/templates/shop_floor_template.html
Normal file
1059
erpnext/public/js/templates/shop_floor_template.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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}}">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user