diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 13f1f3b8757a..1d87af60bab7 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -369,7 +369,7 @@ def validate_fg_item_for_subcontracting(self): item.idx, item.fg_item ) ) - elif not frappe.get_value("Item", item.fg_item, "default_bom"): + elif not item.bom and not frappe.get_value("Item", item.fg_item, "default_bom"): frappe.throw( _("Row #{0}: Default BOM not found for FG Item {1}").format( item.idx, item.fg_item @@ -919,6 +919,14 @@ def get_mapped_subcontracting_order(source_name, target_doc=None): for idx, item in enumerate(target_doc.items): item.warehouse = source_doc.items[idx].warehouse + for idx, item in enumerate(target_doc.items): + item.job_card = source_doc.items[idx].job_card + if not target_doc.supplier_warehouse: + # WIP warehouse is set as Supplier Warehouse in Job Card + target_doc.supplier_warehouse = frappe.get_cached_value( + "Job Card", item.job_card, "wip_warehouse" + ) + return target_doc diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index bce7ed15b125..5f4c9f0fd43d 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -110,7 +110,9 @@ "production_plan", "production_plan_item", "production_plan_sub_assembly_item", - "page_break" + "page_break", + "column_break_pjyo", + "job_card" ], "fields": [ { @@ -909,13 +911,24 @@ { "fieldname": "column_break_fyqr", "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_pjyo", + "fieldtype": "Column Break" + }, + { + "fieldname": "job_card", + "fieldtype": "Link", + "label": "Job Card", + "options": "Job Card", + "search_index": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:24.979325", + "modified": "2024-03-27 13:12:24.979325", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 6267ee4d029d..f2692d21bfff 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -19,6 +19,21 @@ frappe.ui.form.on("BOM", { }; }); + frm.set_query("bom_no", "operations", function (doc, cdt, cdn) { + let row = locals[cdt][cdn]; + return { + query: "erpnext.controllers.queries.bom", + filters: { + currency: frm.doc.currency, + company: frm.doc.company, + item: row.finished_good, + is_active: 1, + docstatus: 1, + track_semi_finished_goods: 0, + }, + }; + }); + frm.set_query("source_warehouse", "items", function () { return { filters: { @@ -85,6 +100,27 @@ frappe.ui.form.on("BOM", { frm.get_field("items").grid.set_multiple_add("item_code", "qty"); }, + default_source_warehouse(frm) { + if (frm.doc.default_source_warehouse) { + frm.doc.operations.forEach((d) => { + frappe.model.set_value( + d.doctype, + d.name, + "source_warehouse", + frm.doc.default_source_warehouse + ); + }); + } + }, + + default_target_warehouse(frm) { + if (frm.doc.default_source_warehouse) { + frm.doc.operations.forEach((d) => { + frappe.model.set_value(d.doctype, d.name, "fg_warehouse", frm.doc.default_target_warehouse); + }); + } + }, + refresh(frm) { frm.toggle_enable("item", frm.doc.__islocal); @@ -96,22 +132,35 @@ frappe.ui.form.on("BOM", { }); if (!frm.is_new() && frm.doc.docstatus < 2) { - frm.add_custom_button(__("Update Cost"), function () { - frm.events.update_cost(frm, true); - }); - frm.add_custom_button(__("Browse BOM"), function () { - frappe.route_options = { - bom: frm.doc.name, - }; - frappe.set_route("Tree", "BOM"); - }); + frm.add_custom_button( + __("Update Cost"), + function () { + frm.events.update_cost(frm, true); + }, + __("Actions") + ); + + frm.add_custom_button( + __("Browse BOM"), + function () { + frappe.route_options = { + bom: frm.doc.name, + }; + frappe.set_route("Tree", "BOM"); + }, + __("Actions") + ); } if (!frm.is_new() && !frm.doc.docstatus == 0) { - frm.add_custom_button(__("New Version"), function () { - let new_bom = frappe.model.copy_doc(frm.doc); - frappe.set_route("Form", "BOM", new_bom.name); - }); + frm.add_custom_button( + __("New Version"), + function () { + let new_bom = frappe.model.copy_doc(frm.doc); + frappe.set_route("Form", "BOM", new_bom.name); + }, + __("Actions") + ); } if (frm.doc.docstatus == 1) { @@ -432,6 +481,28 @@ frappe.ui.form.on("BOM", { }, }); +frappe.ui.form.on("BOM Operation", { + bom_no(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + + if (row.bom_no && row.finished_good) { + frappe.call({ + method: "add_materials_from_bom", + doc: frm.doc, + args: { + finished_good: row.finished_good, + bom_no: row.bom_no, + operation_row_id: row.idx, + qty: row.finished_good_qty, + }, + callback(r) { + refresh_field("items"); + }, + }); + } + }, +}); + erpnext.bom.BomController = class BomController extends erpnext.TransactionController { conversion_rate(doc) { if (this.frm.doc.currency === this.get_company_currency()) { @@ -801,3 +872,88 @@ function trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code) { __("Set Quantity") ); } + +frappe.ui.form.on("BOM Operation", { + add_raw_materials(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + frm.events._prompt_for_raw_materials(frm, row); + }, +}); + +frappe.ui.form.on("BOM", { + _prompt_for_raw_materials(frm, row) { + let fields = frm.events.get_fields_for_prompt(frm, row); + frm._bom_rm_dialog = new frappe.ui.Dialog({ + title: __("Add Raw Materials"), + fields: fields, + primary_action_label: __("Add"), + primary_action: () => { + let values = frm._bom_rm_dialog.get_values(); + if (values) { + frm.events._add_raw_materials(frm, values); + frm._bom_rm_dialog.hide(); + } + }, + }); + + frm._bom_rm_dialog.show(); + }, + + get_fields_for_prompt(frm, row) { + return [ + { + label: __("Raw Materials"), + fieldname: "items", + fieldtype: "Table", + reqd: 1, + fields: [ + { + label: __("Item"), + fieldname: "item_code", + fieldtype: "Link", + options: "Item", + reqd: 1, + in_list_view: 1, + change() { + let doc = this.doc; + doc.qty = 1.0; + this.grid.set_value("qty", 1.0, doc); + }, + get_query() { + return { + filters: { + name: ["!=", row.finished_good], + }, + }; + }, + }, + { + label: __("Qty"), + fieldname: "qty", + default: 1.0, + fieldtype: "Float", + reqd: 1, + in_list_view: 1, + }, + ], + }, + { + fieldname: "operation_row_id", + fieldtype: "Data", + hidden: 1, + default: row.idx, + }, + ]; + }, + + _add_raw_materials(frm, values) { + frm.call({ + method: "add_raw_materials", + doc: frm.doc, + args: { + operation_row_id: values.operation_row_id, + items: values.items, + }, + }); + }, +}); diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index 67de6a0632b9..dcc7a4f21768 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -26,19 +26,22 @@ "column_break_ivyw", "currency", "conversion_rate", - "materials_section", - "items", - "section_break_21", "operations_section_section", "with_operations", + "track_semi_finished_goods", "column_break_23", "transfer_material_against", "routing", "fg_based_operating_cost", + "column_break_joxb", + "default_source_warehouse", + "default_target_warehouse", "fg_based_section_section", "operating_cost_per_bom_quantity", "operations_section", "operations", + "materials_section", + "items", "scrap_section", "scrap_items_section", "scrap_items", @@ -59,8 +62,8 @@ "base_total_cost", "more_info_tab", "item_name", - "description", "column_break_27", + "description", "has_variants", "quality_inspection_section_break", "inspection_required", @@ -211,7 +214,7 @@ }, { "default": "Work Order", - "depends_on": "with_operations", + "depends_on": "eval: doc.with_operations === 1 && doc.track_semi_finished_goods === 0", "fieldname": "transfer_material_against", "fieldtype": "Select", "label": "Transfer Material Against", @@ -406,8 +409,8 @@ { "depends_on": "eval:!doc.__islocal", "fieldname": "section_break0", - "fieldtype": "Section Break", - "label": "Materials Required (Exploded)" + "fieldtype": "Tab Break", + "label": "Exploded Items" }, { "fieldname": "exploded_items", @@ -485,11 +488,6 @@ "fieldtype": "Check", "label": "Show Operations" }, - { - "fieldname": "section_break_21", - "fieldtype": "Tab Break", - "label": "Operations" - }, { "fieldname": "column_break_23", "fieldtype": "Column Break" @@ -534,6 +532,8 @@ "show_dashboard": 1 }, { + "collapsible": 1, + "collapsible_depends_on": "eval:doc.with_operations", "fieldname": "operations_section_section", "fieldtype": "Section Break", "label": "Operations" @@ -617,7 +617,8 @@ "no_copy": 1, "options": "BOM Creator", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "bom_creator_item", @@ -625,11 +626,36 @@ "label": "BOM Creator Item", "no_copy": 1, "print_hide": 1, - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "column_break_oxbz", "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "with_operations", + "description": "Users can consume raw materials and add semi-finished goods or final finished goods against the operation using job cards.", + "fieldname": "track_semi_finished_goods", + "fieldtype": "Check", + "label": "Track Semi Finished Goods" + }, + { + "fieldname": "column_break_joxb", + "fieldtype": "Column Break" + }, + { + "fieldname": "default_source_warehouse", + "fieldtype": "Link", + "label": "Default Source Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "default_target_warehouse", + "fieldtype": "Link", + "label": "Default Target Warehouse", + "options": "Warehouse" } ], "icon": "fa fa-sitemap", @@ -637,7 +663,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2024-04-02 16:22:47.518411", + "modified": "2024-04-02 16:24:47.518411", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 40b4c4f74552..5ff531e797d5 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -10,7 +10,7 @@ from frappe import _ from frappe.core.doctype.version.version import get_diff from frappe.model.mapper import get_mapped_doc -from frappe.utils import cint, cstr, flt, today +from frappe.utils import cint, cstr, flt, parse_json, today from frappe.website.website_generator import WebsiteGenerator import erpnext @@ -125,6 +125,8 @@ class BOM(WebsiteGenerator): company: DF.Link conversion_rate: DF.Float currency: DF.Link + default_source_warehouse: DF.Link | None + default_target_warehouse: DF.Link | None description: DF.SmallText | None exploded_items: DF.Table[BOMExplosionItem] fg_based_operating_cost: DF.Check @@ -136,6 +138,7 @@ class BOM(WebsiteGenerator): item: DF.Link item_name: DF.Data | None items: DF.Table[BOMItem] + track_semi_finished_goods: DF.Check operating_cost: DF.Currency operating_cost_per_bom_quantity: DF.Currency operations: DF.Table[BOMOperation] @@ -245,6 +248,7 @@ def validate(self): self.clear_inspection() self.validate_main_item() self.validate_currency() + self.set_materials_based_on_operation_bom() self.set_conversion_rate() self.set_plc_conversion_rate() self.validate_uom_is_interger() @@ -544,6 +548,9 @@ def clear_operations(self): if not self.with_operations: self.set("operations", []) + if not self.with_operations and self.track_semi_finished_goods: + self.track_semi_finished_goods = 0 + def clear_inspection(self): if not self.inspection_required: self.quality_inspection_template = None @@ -645,6 +652,49 @@ def _throw_error(bom_name): if self.name in {d.bom_no for d in self.items}: _throw_error(self.name) + def set_materials_based_on_operation_bom(self): + if not self.track_semi_finished_goods: + return + + for row in self.get("operations"): + if row.bom_no and row.finished_good: + self.add_materials_from_bom(row.finished_good, row.bom_no, row.idx, qty=row.finished_good_qty) + + @frappe.whitelist() + def add_raw_materials(self, operation_row_id, items): + if isinstance(items, str): + items = parse_json(items) + + for row in items: + row = parse_json(row) + + row.update(get_item_details(row.get("item_code"))) + row.operation_row_id = operation_row_id + row.idx = None + row.name = None + self.append("items", row) + + self.save() + + @frappe.whitelist() + def add_materials_from_bom(self, finished_good, bom_no, operation_row_id, qty=None): + if not frappe.db.exists("BOM", {"item": finished_good, "name": bom_no, "docstatus": 1}): + frappe.throw(_("BOM {0} not found for the item {1}").format(bom_no, finished_good)) + + if not qty: + qty = 1 + + for row in self.items: + if row.operation_row_id == operation_row_id: + return + + bom_items = get_bom_items(bom_no, self.company, qty=qty, fetch_exploded=0) + for row in bom_items: + row.uom = row.stock_uom + row.operation_row_id = operation_row_id + row.idx = None + self.append("items", row) + def traverse_tree(self, bom_list=None): def _get_children(bom_no): children = frappe.cache().hget("bom_children", bom_no) @@ -1094,6 +1144,11 @@ def get_bom_items_as_dict( ): item_dict = {} + group_by_cond = "group by item_code, stock_uom" + if frappe.get_cached_value("BOM", bom, "track_semi_finished_goods"): + fetch_exploded = 0 + group_by_cond = "group by item_code, operation_row_id, stock_uom" + # Did not use qty_consumed_per_unit in the query, as it leads to rounding loss query = """select bom_item.item_code, @@ -1122,7 +1177,7 @@ def get_bom_items_as_dict( and bom.name = %(bom)s and item.is_stock_item in (1, {is_stock_item}) {where_conditions} - group by item_code, stock_uom + {group_by_cond} order by idx""" is_stock_item = 0 if include_non_stock_items else 1 @@ -1132,6 +1187,7 @@ def get_bom_items_as_dict( where_conditions="", is_stock_item=is_stock_item, qty_field="stock_qty", + group_by_cond=group_by_cond, select_columns=""", bom_item.source_warehouse, bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.description, bom_item.rate, bom_item.sourced_by_supplier, (Select idx from `tabBOM Item` where item_code = bom_item.item_code and parent = %(parent)s limit 1) as idx""", @@ -1147,6 +1203,7 @@ def get_bom_items_as_dict( select_columns=", item.description", is_stock_item=is_stock_item, qty_field="stock_qty", + group_by_cond=group_by_cond, ) items = frappe.db.sql(query, {"qty": qty, "bom": bom, "company": company}, as_dict=True) @@ -1158,15 +1215,20 @@ def get_bom_items_as_dict( qty_field="stock_qty" if fetch_qty_in_stock_uom else "qty", select_columns=""", bom_item.uom, bom_item.conversion_factor, bom_item.source_warehouse, bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.sourced_by_supplier, - bom_item.description, bom_item.base_rate as rate """, + bom_item.description, bom_item.base_rate as rate, bom_item.operation_row_id """, + group_by_cond=group_by_cond, ) items = frappe.db.sql(query, {"qty": qty, "bom": bom, "company": company}, as_dict=True) for item in items: - if item.item_code in item_dict: - item_dict[item.item_code]["qty"] += flt(item.qty) + key = item.item_code + if item.operation_row_id: + key = (item.item_code, item.operation_row_id) + + if key in item_dict: + item_dict[key]["qty"] += flt(item.qty) else: - item_dict[item.item_code] = item + item_dict[key] = item for item, item_details in item_dict.items(): for d in [ diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.js b/erpnext/manufacturing/doctype/bom_creator/bom_creator.js index 32231aa49494..b44c9f53f238 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.js +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.js @@ -88,9 +88,77 @@ frappe.ui.form.on("BOM Creator", { reqd: 1, default: 1.0, }, + { fieldtype: "Section Break" }, + { + label: __("Track Operations"), + fieldtype: "Check", + fieldname: "track_operations", + onchange: (r) => { + let track_operations = dialog.get_value("track_operations"); + if (r.type === "input" && !track_operations) { + dialog.set_value("track_semi_finished_goods", 0); + } + }, + }, + { fieldtype: "Column Break" }, + { + label: __("Track Semi Finished Goods"), + fieldtype: "Check", + fieldname: "track_semi_finished_goods", + depends_on: "eval:doc.track_operations", + }, + { + fieldtype: "Section Break", + label: __("Final Product Operation"), + depends_on: "eval:doc.track_semi_finished_goods", + }, + { + label: __("Operation"), + fieldtype: "Link", + fieldname: "operation", + options: "Operation", + default: "Assembly", + mandatory_depends_on: "eval:doc.track_semi_finished_goods", + depends_on: "eval:doc.track_semi_finished_goods", + }, + { + label: __("Operation Time (in mins)"), + fieldtype: "Float", + fieldname: "operation_time", + mandatory_depends_on: "eval:doc.track_semi_finished_goods", + depends_on: "eval:doc.track_semi_finished_goods", + }, + { fieldtype: "Column Break" }, + { + label: __("Workstation Type"), + fieldtype: "Link", + fieldname: "workstation_type", + options: "Workstation", + depends_on: "eval:doc.track_semi_finished_goods", + }, + { + label: __("Workstation"), + fieldtype: "Link", + fieldname: "workstation", + options: "Workstation", + depends_on: "eval:doc.track_semi_finished_goods", + get_query() { + let workstation_type = dialog.get_value("workstation_type"); + + if (workstation_type) { + return { + filters: { + workstation_type: dialog.get_value("workstation_type"), + }, + }; + } + }, + }, ], primary_action_label: __("Create"), primary_action: (values) => { + frm.events.validate_dialog_values(frm, values); + values.doctype = frm.doc.doctype; frappe.db.insert(values).then((doc) => { frappe.set_route("Form", doc.doctype, doc.name); @@ -102,6 +170,18 @@ frappe.ui.form.on("BOM Creator", { dialog.show(); }, + validate_dialog_values(frm, values) { + if (values.track_semi_finished_goods) { + if (values.final_operation_time <= 0) { + frappe.throw(__("Operation Time must be greater than 0")); + } + + if (!values.workstation && !values.workstation_type) { + frappe.throw(__("Either Workstation or Workstation Type is mandatory")); + } + } + }, + set_queries(frm) { frm.set_query("bom_no", "items", function (doc, cdt, cdn) { let item = frappe.get_doc(cdt, cdn); @@ -121,6 +201,16 @@ frappe.ui.form.on("BOM Creator", { query: "erpnext.controllers.queries.item_query", }; }); + + frm.set_query("workstation", (doc) => { + if (doc.workstation_type) { + return { + filters: { + workstation_type: doc.workstation_type, + }, + }; + } + }); }, refresh(frm) { diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.json b/erpnext/manufacturing/doctype/bom_creator/bom_creator.json index 1e8237c03f78..2b4b9a055aa3 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.json +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.json @@ -37,6 +37,23 @@ "items", "costing_detail", "raw_material_cost", + "configuration_section", + "track_operations", + "column_break_obzr", + "track_semi_finished_goods", + "final_product_operation_section", + "operation", + "operation_time", + "column_break_xnlu", + "workstation_type", + "workstation", + "final_product_warehouse_section", + "skip_material_transfer", + "backflush_from_wip_warehouse", + "source_warehouse", + "column_break_buha", + "wip_warehouse", + "fg_warehouse", "remarks_tab", "remarks", "section_break_yixm", @@ -278,6 +295,104 @@ "fieldtype": "Text", "label": "Error Log", "read_only": 1 + }, + { + "fieldname": "configuration_section", + "fieldtype": "Section Break", + "label": "Operation" + }, + { + "default": "0", + "depends_on": "track_operations", + "fieldname": "track_semi_finished_goods", + "fieldtype": "Check", + "label": "Track Semi Finished Goods" + }, + { + "fieldname": "column_break_obzr", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "track_operations", + "fieldtype": "Check", + "label": "Track Operations" + }, + { + "depends_on": "eval:doc.track_semi_finished_goods === 1", + "fieldname": "final_product_operation_section", + "fieldtype": "Section Break", + "label": "Final Product Operation & Workstation" + }, + { + "fieldname": "column_break_xnlu", + "fieldtype": "Column Break" + }, + { + "fieldname": "operation", + "fieldtype": "Link", + "label": "Operation", + "options": "Operation" + }, + { + "fieldname": "operation_time", + "fieldtype": "Float", + "label": "Operation Time (in mins)" + }, + { + "fieldname": "workstation", + "fieldtype": "Link", + "label": "Workstation", + "options": "Workstation" + }, + { + "fieldname": "workstation_type", + "fieldtype": "Link", + "label": "Workstation Type", + "options": "Workstation Type" + }, + { + "depends_on": "eval:!doc.backflush_from_wip_warehouse", + "fieldname": "source_warehouse", + "fieldtype": "Link", + "label": "Source Warehouse", + "options": "Warehouse" + }, + { + "depends_on": "eval:!doc.skip_material_transfer || doc.backflush_from_wip_warehouse", + "fieldname": "wip_warehouse", + "fieldtype": "Link", + "label": "Work In Progress Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "fg_warehouse", + "fieldtype": "Link", + "label": "Finished Good Warehouse", + "options": "Warehouse" + }, + { + "depends_on": "eval:doc.track_semi_finished_goods === 1", + "fieldname": "final_product_warehouse_section", + "fieldtype": "Section Break", + "label": "Final Product Warehouse" + }, + { + "default": "0", + "fieldname": "skip_material_transfer", + "fieldtype": "Check", + "label": "Skip Material Transfer" + }, + { + "default": "0", + "depends_on": "eval:doc.skip_material_transfer", + "fieldname": "backflush_from_wip_warehouse", + "fieldtype": "Check", + "label": "Backflush Materials From WIP Warehouse" + }, + { + "fieldname": "column_break_buha", + "fieldtype": "Column Break" } ], "icon": "fa fa-sitemap", @@ -288,7 +403,7 @@ "link_fieldname": "bom_creator" } ], - "modified": "2024-04-02 16:30:59.779190", + "modified": "2024-05-26 15:47:10.101420", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Creator", diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py index e236e7a63453..69455027f905 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py @@ -43,16 +43,20 @@ class BOMCreator(Document): from erpnext.manufacturing.doctype.bom_creator_item.bom_creator_item import BOMCreatorItem amended_from: DF.Link | None + backflush_from_wip_warehouse: DF.Check buying_price_list: DF.Link | None company: DF.Link conversion_rate: DF.Float currency: DF.Link default_warehouse: DF.Link | None error_log: DF.Text | None + fg_warehouse: DF.Link | None item_code: DF.Link item_group: DF.Link | None item_name: DF.Data | None items: DF.Table[BOMCreatorItem] + operation: DF.Link | None + operation_time: DF.Float plc_conversion_rate: DF.Float price_list_currency: DF.Link | None project: DF.Link | None @@ -61,8 +65,15 @@ class BOMCreator(Document): remarks: DF.TextEditor | None rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List"] set_rate_based_on_warehouse: DF.Check + skip_material_transfer: DF.Check + source_warehouse: DF.Link | None status: DF.Literal["Draft", "Submitted", "In Progress", "Completed", "Failed", "Cancelled"] + track_operations: DF.Check + track_semi_finished_goods: DF.Check uom: DF.Link | None + wip_warehouse: DF.Link | None + workstation: DF.Link | None + workstation_type: DF.Link | None # end: auto-generated types def before_save(self): @@ -236,8 +247,10 @@ def create_boms(self): self.db_set("status", "In Progress") production_item_wise_rm = OrderedDict({}) + + final_product = (self.item_code, self.name) production_item_wise_rm.setdefault( - (self.item_code, self.name), frappe._dict({"items": [], "bom_no": "", "fg_item_data": self}) + final_product, frappe._dict({"items": [], "bom_no": "", "fg_item_data": self}) ) for row in self.items: @@ -257,9 +270,15 @@ def create_boms(self): try: for d in reverse_tree: + if self.track_operations and self.track_semi_finished_goods and final_product == d: + continue + fg_item_data = production_item_wise_rm.get(d).fg_item_data self.create_bom(fg_item_data, production_item_wise_rm) + if self.track_operations and self.track_semi_finished_goods: + self.make_bom_for_final_product(production_item_wise_rm) + frappe.msgprint(_("BOMs created successfully")) except Exception: traceback = frappe.get_traceback(with_context=True) @@ -272,6 +291,81 @@ def create_boms(self): frappe.msgprint(_("BOMs creation failed")) + def make_bom_for_final_product(self, production_item_wise_rm): + bom = frappe.new_doc("BOM") + bom.update( + { + "item": self.item_code, + "bom_type": "Production", + "quantity": self.qty, + "allow_alternative_item": 1, + "bom_creator": self.name, + "bom_creator_item": self.name, + "rm_cost_as_per": "Manual", + "with_operations": 1, + "track_semi_finished_goods": 1, + } + ) + + for field in BOM_FIELDS: + if self.get(field): + bom.set(field, self.get(field)) + + for item in self.items: + if not item.is_expandable or not item.operation: + continue + + bom.append( + "operations", + { + "operation": item.operation, + "workstation": item.workstation, + "source_warehouse": item.source_warehouse, + "wip_warehouse": item.wip_warehouse, + "fg_warehouse": item.fg_warehouse, + "finished_good": item.item_code, + "finished_good_qty": item.qty, + "bom_no": production_item_wise_rm[(item.item_code, item.name)].bom_no, + "workstation_type": item.workstation_type, + "time_in_mins": item.operation_time, + "is_subcontracted": item.is_subcontracted, + "skip_material_transfer": item.skip_material_transfer, + "backflush_from_wip_warehouse": item.backflush_from_wip_warehouse, + }, + ) + + operation_row = bom.append( + "operations", + { + "operation": self.operation, + "time_in_mins": self.operation_time, + "workstation": self.workstation, + "workstation_type": self.workstation_type, + "finished_good": self.item_code, + "finished_good_qty": self.qty, + "source_warehouse": self.source_warehouse, + "wip_warehouse": self.wip_warehouse, + "fg_warehouse": self.fg_warehouse, + "skip_material_transfer": self.skip_material_transfer, + "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, + }, + ) + + final_product = (self.item_code, self.name) + items = production_item_wise_rm.get(final_product).get("items") + + bom.set_materials_based_on_operation_bom() + + for item in items: + item_args = {"operation_row_id": operation_row.idx} + for field in BOM_ITEM_FIELDS: + item_args[field] = item.get(field) + + bom.append("items", item_args) + + bom.save(ignore_permissions=True) + bom.submit() + def create_bom(self, row, production_item_wise_rm): bom_creator_item = row.name if row.name != self.name else "" if frappe.db.exists( @@ -297,6 +391,24 @@ def create_bom(self, row, production_item_wise_rm): } ) + if self.track_operations and not self.track_semi_finished_goods: + if row.item_code == self.item_code: + bom.with_operations = 1 + bom.transfer_material_against = "Work Order" + for item in self.items: + if not item.operation: + continue + + bom.append( + "operations", + { + "operation": item.operation, + "workstation_type": item.workstation_type, + "workstation": item.workstation, + "time_in_mins": item.operation_time, + }, + ) + for field in BOM_FIELDS: if self.get(field): bom.set(field, self.get(field)) @@ -352,6 +464,16 @@ def get_children(doctype=None, parent=None, **kwargs): "uom", "rate", "amount", + "workstation_type", + "operation", + "operation_time", + "is_subcontracted", + "workstation", + "source_warehouse", + "wip_warehouse", + "fg_warehouse", + "skip_material_transfer", + "backflush_from_wip_warehouse", ] query_filters = { @@ -365,6 +487,12 @@ def get_children(doctype=None, parent=None, **kwargs): return frappe.get_all("BOM Creator Item", fields=fields, filters=query_filters, order_by="idx") +def get_parent_row_no(doc, name): + for row in doc.items: + if row.name == name: + return row.idx + + @frappe.whitelist() def add_item(**kwargs): if isinstance(kwargs, str): @@ -375,6 +503,11 @@ def add_item(**kwargs): doc = frappe.get_doc("BOM Creator", kwargs.parent) item_info = get_item_details(kwargs.item_code) + + parent_row_no = "" + if kwargs.fg_reference_id and doc.name != kwargs.fg_reference_id: + parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id) + kwargs.update( { "uom": item_info.stock_uom, @@ -383,6 +516,9 @@ def add_item(**kwargs): } ) + if parent_row_no: + kwargs.update({"parent_row_no": parent_row_no}) + doc.append("items", kwargs) doc.save() @@ -402,6 +538,7 @@ def add_sub_assembly(**kwargs): name = kwargs.fg_reference_id parent_row_no = "" + if not kwargs.convert_to_sub_assembly: item_info = get_item_details(bom_item.item_code) item_row = doc.append( @@ -417,6 +554,14 @@ def add_sub_assembly(**kwargs): "do_not_explode": 1, "is_expandable": 1, "stock_uom": item_info.stock_uom, + "operation": bom_item.operation, + "workstation_type": bom_item.workstation_type, + "operation_time": bom_item.operation_time, + "is_subcontracted": bom_item.is_subcontracted, + "workstation": bom_item.workstation, + "source_warehouse": bom_item.source_warehouse, + "wip_warehouse": bom_item.wip_warehouse, + "fg_warehouse": bom_item.fg_warehouse, }, ) @@ -426,6 +571,20 @@ def add_sub_assembly(**kwargs): parent_row_no = [row.idx for row in doc.items if row.name == kwargs.fg_reference_id] if parent_row_no: parent_row_no = parent_row_no[0] + doc.items[parent_row_no - 1].update( + { + "operation": bom_item.operation, + "workstation_type": bom_item.workstation_type, + "operation_time": bom_item.operation_time, + "is_subcontracted": bom_item.is_subcontracted, + "workstation": bom_item.workstation, + "source_warehouse": bom_item.source_warehouse, + "wip_warehouse": bom_item.wip_warehouse, + "fg_warehouse": bom_item.fg_warehouse, + "skip_material_transfer": bom_item.skip_material_transfer, + "backflush_from_wip_warehouse": bom_item.backflush_from_wip_warehouse, + } + ) for row in bom_item.get("items"): row = frappe._dict(row) @@ -482,10 +641,16 @@ def delete_node(**kwargs): @frappe.whitelist() -def edit_qty(doctype, docname, qty, parent): - frappe.db.set_value(doctype, docname, "qty", qty) +def edit_bom_creator(doctype, docname, data, parent): + if isinstance(data, str): + data = frappe.parse_json(data) + + frappe.db.set_value(doctype, docname, data) + doc = frappe.get_doc("BOM Creator", parent) doc.set_rate_for_items() doc.save() + frappe.msgprint(_("Updated successfully"), alert=True) + return doc diff --git a/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json b/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json index e9545ac5385a..9be55667f803 100644 --- a/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json +++ b/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json @@ -11,10 +11,23 @@ "item_group", "column_break_f63f", "fg_item", - "source_warehouse", "is_expandable", "sourced_by_supplier", "bom_created", + "is_subcontracted", + "operation_section", + "operation", + "operation_time", + "column_break_cbnk", + "workstation_type", + "workstation", + "warehouse_section", + "skip_material_transfer", + "backflush_from_wip_warehouse", + "source_warehouse", + "column_break_xutc", + "wip_warehouse", + "fg_warehouse", "description_section", "description", "quantity_and_rate_section", @@ -75,16 +88,18 @@ "reqd": 1 }, { + "depends_on": "eval:doc.skip_material_transfer && !doc.backflush_from_wip_warehouse", "fieldname": "source_warehouse", "fieldtype": "Link", - "in_list_view": 1, "label": "Source Warehouse", "options": "Warehouse" }, { + "columns": 1, "default": "0", "fieldname": "is_expandable", "fieldtype": "Check", + "in_list_view": 1, "label": "Is Expandable", "read_only": 1 }, @@ -225,12 +240,87 @@ "label": "BOM Created", "no_copy": 1, "print_hide": 1 + }, + { + "fieldname": "operation_section", + "fieldtype": "Section Break", + "label": "Operation" + }, + { + "fieldname": "operation", + "fieldtype": "Link", + "label": "Operation", + "options": "Operation" + }, + { + "fieldname": "column_break_cbnk", + "fieldtype": "Column Break" + }, + { + "fieldname": "workstation_type", + "fieldtype": "Link", + "label": "Workstation Type", + "options": "Workstation Type" + }, + { + "description": "In Mins", + "fieldname": "operation_time", + "fieldtype": "Int", + "label": "Operation Time" + }, + { + "fieldname": "workstation", + "fieldtype": "Link", + "label": "Workstation", + "options": "Workstation" + }, + { + "fieldname": "warehouse_section", + "fieldtype": "Section Break", + "label": "Warehouse" + }, + { + "depends_on": "eval:!doc.skip_material_transfer || doc.backflush_from_wip_warehouse", + "fieldname": "wip_warehouse", + "fieldtype": "Link", + "label": "Work In Progress Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "column_break_xutc", + "fieldtype": "Column Break" + }, + { + "fieldname": "fg_warehouse", + "fieldtype": "Link", + "label": "Finished Good Warehouse", + "options": "Warehouse" + }, + { + "default": "0", + "fieldname": "skip_material_transfer", + "fieldtype": "Check", + "label": "Skip Material Transfer" + }, + { + "default": "0", + "depends_on": "eval:doc.skip_material_transfer", + "fieldname": "backflush_from_wip_warehouse", + "fieldtype": "Check", + "label": "Backflush Materials From WIP Warehouse" + }, + { + "default": "0", + "fieldname": "is_subcontracted", + "fieldtype": "Check", + "label": "Is Subcontracted", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:06:40.764747", + "modified": "2024-06-01 18:45:24.339532", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Creator Item", diff --git a/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.py b/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.py index e172f36224d8..2510a02ddc71 100644 --- a/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.py +++ b/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.py @@ -15,6 +15,7 @@ class BOMCreatorItem(Document): from frappe.types import DF amount: DF.Currency + backflush_from_wip_warehouse: DF.Check base_amount: DF.Currency base_rate: DF.Currency bom_created: DF.Check @@ -23,22 +24,30 @@ class BOMCreatorItem(Document): do_not_explode: DF.Check fg_item: DF.Link fg_reference_id: DF.Data | None + fg_warehouse: DF.Link | None instruction: DF.SmallText | None is_expandable: DF.Check + is_subcontracted: DF.Check item_code: DF.Link item_group: DF.Link | None item_name: DF.Data | None + operation: DF.Link | None + operation_time: DF.Int parent: DF.Data parent_row_no: DF.Data | None parentfield: DF.Data parenttype: DF.Data qty: DF.Float rate: DF.Currency + skip_material_transfer: DF.Check source_warehouse: DF.Link | None sourced_by_supplier: DF.Check stock_qty: DF.Float stock_uom: DF.Link | None uom: DF.Link | None + wip_warehouse: DF.Link | None + workstation: DF.Link | None + workstation_type: DF.Link | None # end: auto-generated types pass diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.json b/erpnext/manufacturing/doctype/bom_item/bom_item.json index 226cfe0162fb..1d530af34a28 100644 --- a/erpnext/manufacturing/doctype/bom_item/bom_item.json +++ b/erpnext/manufacturing/doctype/bom_item/bom_item.json @@ -9,6 +9,7 @@ "item_code", "item_name", "operation", + "operation_row_id", "column_break_3", "do_not_explode", "bom_no", @@ -293,13 +294,19 @@ "fieldtype": "Check", "label": "Is Stock Item", "read_only": 1 + }, + { + "depends_on": "eval:parent.track_semi_finished_goods ==1", + "fieldname": "operation_row_id", + "fieldtype": "Int", + "label": "Operation ID" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:06:41.079752", + "modified": "2024-03-27 13:08:41.079752", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Item", diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.py b/erpnext/manufacturing/doctype/bom_item/bom_item.py index 466253bf0bfa..87430d7d47d2 100644 --- a/erpnext/manufacturing/doctype/bom_item/bom_item.py +++ b/erpnext/manufacturing/doctype/bom_item/bom_item.py @@ -25,9 +25,11 @@ class BOMItem(Document): has_variants: DF.Check image: DF.Attach | None include_item_in_manufacturing: DF.Check + is_stock_item: DF.Check item_code: DF.Link item_name: DF.Data | None operation: DF.Link | None + operation_row_id: DF.Int original_item: DF.Link | None parent: DF.Data parentfield: DF.Data diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index aa62b027b06f..b9e960ab66e0 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json @@ -6,24 +6,36 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "sequence_id", "operation", + "sequence_id", + "finished_good", + "finished_good_qty", + "bom_no", + "add_raw_materials", "col_break1", "workstation_type", "workstation", "time_in_mins", "fixed_time", + "is_subcontracted", + "is_final_finished_good", + "set_cost_based_on_bom_qty", + "warehouse_section", + "skip_material_transfer", + "backflush_from_wip_warehouse", + "source_warehouse", + "column_break_lbhy", + "wip_warehouse", + "fg_warehouse", "costing_section", "hour_rate", "base_hour_rate", - "column_break_9", - "operating_cost", - "base_operating_cost", - "column_break_11", "batch_size", - "set_cost_based_on_bom_qty", + "column_break_11", "cost_per_unit", "base_cost_per_unit", + "operating_cost", + "base_operating_cost", "more_information_section", "description", "column_break_18", @@ -71,6 +83,7 @@ "precision": "2" }, { + "columns": 1, "description": "In minutes", "fetch_from": "operation.total_operation_time", "fetch_if_empty": 1, @@ -87,7 +100,6 @@ "description": "Operation time does not depend on quantity to produce", "fieldname": "fixed_time", "fieldtype": "Check", - "in_list_view": 1, "label": "Fixed Time" }, { @@ -172,10 +184,6 @@ "fieldname": "column_break_18", "fieldtype": "Column Break" }, - { - "fieldname": "column_break_9", - "fieldtype": "Column Break" - }, { "default": "0", "fieldname": "set_cost_based_on_bom_qty", @@ -183,18 +191,106 @@ "label": "Set Operating Cost Based On BOM Quantity" }, { + "columns": 1, "fieldname": "workstation_type", "fieldtype": "Link", "in_list_view": 1, "label": "Workstation Type", "options": "Workstation Type" + }, + { + "fieldname": "finished_good", + "fieldtype": "Link", + "in_list_view": 1, + "label": "FG / Semi FG Item", + "options": "Item" + }, + { + "columns": 1, + "fieldname": "bom_no", + "fieldtype": "Link", + "in_list_view": 1, + "label": "BOM No", + "options": "BOM" + }, + { + "columns": 1, + "default": "1", + "fieldname": "finished_good_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "FG Qty" + }, + { + "default": "0", + "fieldname": "is_final_finished_good", + "fieldtype": "Check", + "label": "Is Final Finished Good" + }, + { + "fieldname": "warehouse_section", + "fieldtype": "Section Break", + "label": "Warehouse" + }, + { + "depends_on": "eval:!doc.skip_material_transfer || doc.backflush_from_wip_warehouse", + "fieldname": "wip_warehouse", + "fieldtype": "Link", + "label": "WIP WH", + "options": "Warehouse" + }, + { + "fieldname": "column_break_lbhy", + "fieldtype": "Column Break" + }, + { + "columns": 1, + "fieldname": "fg_warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "FG WH", + "options": "Warehouse" + }, + { + "columns": 1, + "depends_on": "eval:doc.skip_material_transfer && !doc.backflush_from_wip_warehouse", + "fieldname": "source_warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Source WH", + "options": "Warehouse" + }, + { + "default": "0", + "fieldname": "is_subcontracted", + "fieldtype": "Check", + "label": "Is Subcontracted" + }, + { + "depends_on": "eval:!doc.bom_no", + "fieldname": "add_raw_materials", + "fieldtype": "Button", + "label": "Add Raw Materials" + }, + { + "default": "0", + "fieldname": "skip_material_transfer", + "fieldtype": "Check", + "label": " Skip Material Transfer" + }, + { + "default": "0", + "depends_on": "eval:doc.skip_material_transfer", + "fieldname": "backflush_from_wip_warehouse", + "fieldtype": "Check", + "label": "Backflush Materials From WIP Warehouse" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:06:41.248462", + "modified": "2024-05-26 15:46:49.404875", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Operation", diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.py b/erpnext/manufacturing/doctype/bom_operation/bom_operation.py index 66ac02891b99..fd197e89e62a 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.py +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.py @@ -14,15 +14,22 @@ class BOMOperation(Document): if TYPE_CHECKING: from frappe.types import DF + backflush_from_wip_warehouse: DF.Check base_cost_per_unit: DF.Float base_hour_rate: DF.Currency base_operating_cost: DF.Currency batch_size: DF.Int + bom_no: DF.Link | None cost_per_unit: DF.Float description: DF.TextEditor | None + fg_warehouse: DF.Link | None + finished_good: DF.Link | None + finished_good_qty: DF.Float fixed_time: DF.Check hour_rate: DF.Currency image: DF.Attach | None + is_final_finished_good: DF.Check + is_subcontracted: DF.Check operating_cost: DF.Currency operation: DF.Link parent: DF.Data @@ -30,7 +37,10 @@ class BOMOperation(Document): parenttype: DF.Data sequence_id: DF.Int set_cost_based_on_bom_qty: DF.Check + skip_material_transfer: DF.Check + source_warehouse: DF.Link | None time_in_mins: DF.Float + wip_warehouse: DF.Link | None workstation: DF.Link | None workstation_type: DF.Link | None # end: auto-generated types diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 4cc60a3b4a6d..2de5d9dad115 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -32,21 +32,61 @@ frappe.ui.form.on("Job Card", { }); }, + make_fields_read_only(frm) { + if (frm.doc.docstatus === 1) { + frm.set_df_property("employee", "read_only", 1); + frm.set_df_property("time_logs", "read_only", 1); + } + + if (frm.doc.is_subcontracted) { + frm.set_df_property("wip_warehouse", "label", __("Supplier Warehouse")); + } + }, + + setup_stock_entry(frm) { + if ( + frm.doc.finished_good && + frm.doc.docstatus === 1 && + !frm.doc.is_subcontracted && + flt(frm.doc.for_quantity) + flt(frm.doc.process_loss_qty) > flt(frm.doc.manufactured_qty) + ) { + frm.add_custom_button(__("Make Stock Entry"), () => { + frm.call({ + method: "make_stock_entry_for_semi_fg_item", + args: { + auto_submit: 1, + }, + doc: frm.doc, + freeze: true, + callback() { + frm.reload_doc(); + }, + }); + }).addClass("btn-primary"); + } + }, + refresh: function (frm) { - frappe.flags.pause_job = 0; - frappe.flags.resume_job = 0; + frm.trigger("setup_stock_entry"); + let has_items = frm.doc.items && frm.doc.items.length; + frm.trigger("make_fields_read_only"); if (!frm.is_new() && frm.doc.__onload.work_order_closed) { frm.disable_save(); return; } + if (frm.doc.is_subcontracted) { + frm.trigger("make_subcontracting_po"); + return; + } + let has_stock_entry = frm.doc.__onload && frm.doc.__onload.has_stock_entry ? true : false; frm.toggle_enable("for_quantity", !has_stock_entry); - if (!frm.is_new() && has_items && frm.doc.docstatus < 2) { + if (!frm.is_new() && !frm.doc.skip_material_transfer && has_items && frm.doc.docstatus < 2) { let to_request = frm.doc.for_quantity > frm.doc.transferred_qty; let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer; @@ -63,11 +103,11 @@ frappe.ui.form.on("Job Card", { if (to_transfer || excess_transfer_allowed) { frm.add_custom_button(__("Material Transfer"), () => { frm.trigger("make_stock_entry"); - }).addClass("btn-primary"); + }); } } - if (frm.doc.docstatus == 1 && !frm.doc.is_corrective_job_card) { + if (frm.doc.docstatus == 1 && !frm.doc.is_corrective_job_card && !frm.doc.finished_good) { frm.trigger("setup_corrective_job_card"); } @@ -84,31 +124,67 @@ frappe.ui.form.on("Job Card", { frm.trigger("toggle_operation_number"); if ( - frm.doc.docstatus == 0 && - !frm.is_new() && - (frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity) && - (frm.doc.items || !frm.doc.items.length || frm.doc.for_quantity == frm.doc.transferred_qty) + frm.doc.for_quantity + frm.doc.process_loss_qty > frm.doc.total_completed_qty && + (frm.doc.skip_material_transfer || + frm.doc.transferred_qty >= frm.doc.for_quantity + frm.doc.process_loss_qty || + !frm.doc.finished_good) ) { - // if Job Card is link to Work Order, the job card must not be able to start if Work Order not "Started" - // and if stock mvt for WIP is required - if (frm.doc.work_order) { - frappe.db.get_value( - "Work Order", - frm.doc.work_order, - ["skip_transfer", "status"], - (result) => { - if ( - result.skip_transfer === 1 || - result.status == "In Process" || - frm.doc.transferred_qty > 0 || - !frm.doc.items.length - ) { - frm.trigger("prepare_timer_buttons"); - } + if (!frm.doc.time_logs?.length) { + frm.add_custom_button(__("Start Job"), () => { + let from_time = frappe.datetime.now_datetime(); + if ((frm.doc.employee && !frm.doc.employee.length) || !frm.doc.employee) { + frappe.prompt( + { + fieldtype: "Table MultiSelect", + label: __("Select Employees"), + options: "Job Card Time Log", + fieldname: "employees", + }, + (d) => { + frm.events.start_timer(frm, from_time, d.employees); + }, + __("Assign Job to Employee") + ); + } else { + frm.events.start_timer(frm, from_time, frm.doc.employee); } - ); + }); + } else if (frm.doc.is_paused) { + frm.add_custom_button(__("Resume Job"), () => { + frm.call({ + method: "resume_job", + doc: frm.doc, + args: { + start_time: frappe.datetime.now_datetime(), + }, + callback() { + frm.reload_doc(); + }, + }); + }); } else { - frm.trigger("prepare_timer_buttons"); + if (frm.doc.for_quantity - frm.doc.manufactured_qty > 0) { + if (!frm.doc.is_paused) { + frm.add_custom_button(__("Pause Job"), () => { + frm.call({ + method: "pause_job", + doc: frm.doc, + args: { + end_time: frappe.datetime.now_datetime(), + }, + callback() { + frm.reload_doc(); + }, + }); + }); + } + + frm.add_custom_button(__("Complete Job"), () => { + frm.trigger("complete_job_card"); + }); + } + + frm.trigger("make_dashboard"); } } @@ -116,7 +192,7 @@ frappe.ui.form.on("Job Card", { if (frm.doc.work_order) { frappe.db.get_value("Work Order", frm.doc.work_order, "transfer_material_against").then((r) => { - if (r.message.transfer_material_against == "Work Order") { + if (r.message.transfer_material_against == "Work Order" && !frm.doc.operation_row_id) { frm.set_df_property("items", "hidden", 1); } }); @@ -134,6 +210,75 @@ frappe.ui.form.on("Job Card", { } }, + make_subcontracting_po(frm) { + if (frm.doc.docstatus === 1 && frm.doc.for_quantity > frm.doc.manufactured_qty) { + frm.add_custom_button(__("Make Subcontracting PO"), () => { + frappe.model.open_mapped_doc({ + method: "erpnext.manufacturing.doctype.job_card.job_card.make_subcontracting_po", + frm: frm, + }); + }).addClass("btn-primary"); + } + }, + + start_timer(frm, start_time, employees) { + frm.call({ + method: "start_timer", + doc: frm.doc, + args: { + start_time: start_time, + employees: employees, + }, + callback: function (r) { + frm.reload_doc(); + frm.trigger("make_dashboard"); + }, + }); + }, + + make_finished_good(frm) { + let fields = [ + { + fieldtype: "Float", + label: __("Completed Quantity"), + fieldname: "qty", + reqd: 1, + default: frm.doc.for_quantity - frm.doc.manufactured_qty, + }, + { + fieldtype: "Datetime", + label: __("End Time"), + fieldname: "end_time", + default: frappe.datetime.now_datetime(), + }, + ]; + + frappe.prompt( + fields, + (data) => { + if (data.qty <= 0) { + frappe.throw(__("Quantity should be greater than 0")); + } + + frm.call({ + method: "make_finished_good", + doc: frm.doc, + args: { + qty: data.qty, + end_time: data.end_time, + }, + callback: function (r) { + var doc = frappe.model.sync(r.message); + frappe.set_route("Form", doc[0].doctype, doc[0].name); + }, + }); + }, + __("Enter Value"), + __("Update"), + __("Set Finished Good Quantity") + ); + }, + setup_quality_inspection: function (frm) { let quality_inspection_field = frm.get_docfield("quality_inspection"); quality_inspection_field.get_route_options_for_new_doc = function (frm) { @@ -262,90 +407,6 @@ frappe.ui.form.on("Job Card", { frm.toggle_reqd("operation_row_number", !frm.doc.operation_id && frm.doc.operation); }, - prepare_timer_buttons: function (frm) { - frm.trigger("make_dashboard"); - - if (!frm.doc.started_time && !frm.doc.current_time) { - frm.add_custom_button(__("Start Job"), () => { - if ((frm.doc.employee && !frm.doc.employee.length) || !frm.doc.employee) { - frappe.prompt( - { - fieldtype: "Table MultiSelect", - label: __("Select Employees"), - options: "Job Card Time Log", - fieldname: "employees", - }, - (d) => { - frm.events.start_job(frm, "Work In Progress", d.employees); - }, - __("Assign Job to Employee") - ); - } else { - frm.events.start_job(frm, "Work In Progress", frm.doc.employee); - } - }).addClass("btn-primary"); - } else if (frm.doc.status == "On Hold") { - frm.add_custom_button(__("Resume Job"), () => { - frm.events.start_job(frm, "Resume Job", frm.doc.employee); - }).addClass("btn-primary"); - } else { - frm.add_custom_button(__("Pause Job"), () => { - frm.events.complete_job(frm, "On Hold"); - }); - - frm.add_custom_button(__("Complete Job"), () => { - var sub_operations = frm.doc.sub_operations; - - let set_qty = true; - if (sub_operations && sub_operations.length > 1) { - set_qty = false; - let last_op_row = sub_operations[sub_operations.length - 2]; - - if (last_op_row.status == "Complete") { - set_qty = true; - } - } - - if (set_qty) { - frappe.prompt( - { - fieldtype: "Float", - label: __("Completed Quantity"), - fieldname: "qty", - default: frm.doc.for_quantity, - }, - (data) => { - frm.events.complete_job(frm, "Complete", data.qty); - }, - __("Enter Value") - ); - } else { - frm.events.complete_job(frm, "Complete", 0.0); - } - }).addClass("btn-primary"); - } - }, - - start_job: function (frm, status, employee) { - const args = { - job_card_id: frm.doc.name, - start_time: frappe.datetime.now_datetime(), - employees: employee, - status: status, - }; - frm.events.make_time_log(frm, args); - }, - - complete_job: function (frm, status, completed_qty) { - const args = { - job_card_id: frm.doc.name, - complete_time: frappe.datetime.now_datetime(), - status: status, - completed_qty: completed_qty, - }; - frm.events.make_time_log(frm, args); - }, - make_time_log: function (frm, args) { frm.events.update_sub_operation(frm, args); @@ -392,7 +453,7 @@ frappe.ui.form.on("Job Card", { function updateStopwatch(increment) { var hours = Math.floor(increment / 3600); var minutes = Math.floor((increment - hours * 3600) / 60); - var seconds = increment - hours * 3600 - minutes * 60; + var seconds = flt(increment - hours * 3600 - minutes * 60, 2); $(section) .find(".hours") @@ -415,7 +476,7 @@ frappe.ui.form.on("Job Card", { frm.dashboard.refresh(); const timer = `
00 : 00 @@ -424,19 +485,32 @@ frappe.ui.form.on("Job Card", {
`; var section = frm.toolbar.page.add_inner_message(timer); + let currentIncrement = frm.events.get_current_time(frm); + if (frm.doc.time_logs?.length && frm.doc.time_logs[cint(frm.doc.time_logs.length) - 1].to_time) { + updateStopwatch(currentIncrement); + } else if (frm.doc.status == "On Hold") { + updateStopwatch(currentIncrement); + } else { + initialiseTimer(); + } + }, - let currentIncrement = frm.doc.current_time || 0; - if (frm.doc.started_time || frm.doc.current_time) { - if (frm.doc.status == "On Hold") { - updateStopwatch(currentIncrement); + get_current_time(frm) { + let current_time = 0; + + frm.doc.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 += get_seconds_diff(d.to_time, d.from_time); + } } else { - currentIncrement += moment(frappe.datetime.now_datetime()).diff( - moment(frm.doc.started_time), - "seconds" - ); - initialiseTimer(); + current_time += get_seconds_diff(frappe.datetime.now_datetime(), d.from_time); } - } + }); + + return current_time; }, hide_timer: function (frm) { @@ -492,6 +566,14 @@ frappe.ui.form.on("Job Card", { refresh_field("total_completed_qty"); }, + + source_warehouse(frm) { + if (frm.doc.source_warehouse) { + frm.doc.items.forEach((d) => { + frappe.model.set_value(d.doctype, d.name, "source_warehouse", frm.doc.source_warehouse); + }); + } + }, }); frappe.ui.form.on("Job Card Time Log", { @@ -503,3 +585,7 @@ frappe.ui.form.on("Job Card Time Log", { frm.set_value("started_time", ""); }, }); + +function get_seconds_diff(d1, d2) { + return moment(d1).diff(d2, "seconds"); +} diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 531c71f9c634..9a0d1c69a1b7 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -8,43 +8,57 @@ "field_order": [ "naming_series", "work_order", - "bom_no", - "production_item", "employee", + "is_subcontracted", "column_break_4", "posting_date", "company", - "for_quantity", + "project", + "bom_no", + "semi_finished_good__finished_good_section", + "finished_good", + "production_item", + "semi_fg_bom", "total_completed_qty", + "column_break_mcnb", + "for_quantity", + "transferred_qty", + "manufactured_qty", "process_loss_qty", + "production_section", + "operation", + "source_warehouse", + "wip_warehouse", + "skip_material_transfer", + "backflush_from_wip_warehouse", + "column_break_12", + "workstation_type", + "workstation", + "target_warehouse", + "section_break_8", + "items", + "quality_inspection_section", + "quality_inspection_template", + "column_break_fcmp", + "quality_inspection", + "scheduled_time_tab", "scheduled_time_section", "expected_start_date", "time_required", "column_break_jkir", "expected_end_date", - "section_break_05am", + "section_break_rzeo", "scheduled_time_logs", "timing_detail", - "time_logs", "section_break_13", "actual_start_date", "total_time_in_mins", "column_break_15", "actual_end_date", - "production_section", - "operation", - "wip_warehouse", - "column_break_12", - "workstation_type", - "workstation", - "quality_inspection_section", - "quality_inspection_template", - "column_break_fcmp", - "quality_inspection", + "section_break_jbas", + "time_logs", "section_break_21", "sub_operations", - "section_break_8", - "items", "scrap_items_section", "scrap_items", "corrective_operation_section", @@ -54,11 +68,11 @@ "hour_rate", "for_operation", "more_information", - "project", "item_name", - "transferred_qty", "requested_qty", "status", + "operation_row_id", + "is_paused", "column_break_20", "operation_row_number", "operation_id", @@ -68,7 +82,6 @@ "batch_no", "serial_no", "barcode", - "job_started", "started_time", "current_time", "amended_from", @@ -86,10 +99,11 @@ "search_index": 1 }, { + "depends_on": "eval:!doc.finished_good", "fetch_from": "work_order.bom_no", "fieldname": "bom_no", "fieldtype": "Link", - "label": "BOM No", + "label": "Final BOM", "options": "BOM", "read_only": 1 }, @@ -105,6 +119,7 @@ "fieldname": "operation", "fieldtype": "Link", "in_list_view": 1, + "in_preview": 1, "label": "Operation", "options": "Operation", "reqd": 1 @@ -130,22 +145,24 @@ "fieldname": "for_quantity", "fieldtype": "Float", "in_list_view": 1, + "in_preview": 1, "label": "Qty To Manufacture" }, { "fieldname": "wip_warehouse", "fieldtype": "Link", "label": "WIP Warehouse", - "options": "Warehouse", - "reqd": 1 + "mandatory_depends_on": "eval:!doc.finished_good || doc.skip_material_transfer === 0 || (doc.skip_material_transfer && doc.backflush_from_wip_warehouse)", + "options": "Warehouse" }, { "fieldname": "timing_detail", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Actual Time" }, { "allow_bulk_edit": 1, + "allow_on_submit": 1, "fieldname": "time_logs", "fieldtype": "Table", "label": "Time Logs", @@ -157,9 +174,12 @@ "hide_border": 1 }, { + "allow_on_submit": 1, "default": "0", + "depends_on": "eval:doc.is_subcontracted===0", "fieldname": "total_completed_qty", "fieldtype": "Float", + "in_preview": 1, "label": "Total Completed Qty", "read_only": 1 }, @@ -175,7 +195,7 @@ }, { "fieldname": "section_break_8", - "fieldtype": "Tab Break", + "fieldtype": "Section Break", "label": "Raw Materials" }, { @@ -199,9 +219,10 @@ }, { "default": "0", + "depends_on": "items", "fieldname": "transferred_qty", "fieldtype": "Float", - "label": "FG Qty from Transferred Raw Materials", + "label": "Transferred Raw Materials", "read_only": 1 }, { @@ -227,6 +248,7 @@ "fieldtype": "Column Break" }, { + "allow_on_submit": 1, "default": "Open", "fieldname": "status", "fieldtype": "Select", @@ -236,16 +258,7 @@ "read_only": 1 }, { - "default": "0", - "fieldname": "job_started", - "fieldtype": "Check", - "hidden": 1, - "label": "Job Started", - "no_copy": 1, - "print_hide": 1, - "read_only": 1 - }, - { + "allow_on_submit": 1, "fieldname": "started_time", "fieldtype": "Datetime", "hidden": 1, @@ -273,18 +286,19 @@ }, { "fieldname": "production_section", - "fieldtype": "Tab Break", - "label": "Operation & Workstation" + "fieldtype": "Section Break", + "label": "Operation & Materials" }, { "fieldname": "column_break_12", "fieldtype": "Column Break" }, { + "depends_on": "eval:!doc.finished_good", "fetch_from": "work_order.production_item", "fieldname": "production_item", "fieldtype": "Link", - "label": "Production Item", + "label": "Final Product", "options": "Item", "read_only": 1 }, @@ -302,6 +316,7 @@ "label": "Item Name" }, { + "allow_on_submit": 1, "fieldname": "current_time", "fieldtype": "Int", "hidden": 1, @@ -384,6 +399,7 @@ "options": "Operation" }, { + "allow_on_submit": 1, "fieldname": "employee", "fieldtype": "Table MultiSelect", "label": "Employee", @@ -463,6 +479,7 @@ "show_dashboard": 1 }, { + "depends_on": "expected_start_date", "fieldname": "scheduled_time_section", "fieldtype": "Section Break", "label": "Scheduled Time" @@ -476,10 +493,6 @@ "fieldtype": "Float", "label": "Expected Time Required (In Mins)" }, - { - "fieldname": "section_break_05am", - "fieldtype": "Section Break" - }, { "fieldname": "scheduled_time_logs", "fieldtype": "Table", @@ -507,11 +520,101 @@ { "fieldname": "column_break_fcmp", "fieldtype": "Column Break" + }, + { + "fieldname": "finished_good", + "fieldtype": "Link", + "in_preview": 1, + "label": "Finished Good", + "options": "Item", + "read_only": 1 + }, + { + "fieldname": "target_warehouse", + "fieldtype": "Link", + "label": "Target Warehouse", + "mandatory_depends_on": "eval:doc.finished_good", + "options": "Warehouse" + }, + { + "fieldname": "operation_row_id", + "fieldtype": "Int", + "label": "Operation Row ID" + }, + { + "fieldname": "source_warehouse", + "fieldtype": "Link", + "label": "Source Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "semi_finished_good__finished_good_section", + "fieldtype": "Section Break", + "label": "Semi Finished Good / Finished Good" + }, + { + "fieldname": "semi_fg_bom", + "fieldtype": "Link", + "label": "Semi FG BOM", + "options": "BOM", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_subcontracted", + "fieldtype": "Check", + "label": " Is Subcontracted", + "read_only": 1 + }, + { + "fieldname": "column_break_mcnb", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_rzeo", + "fieldtype": "Section Break" + }, + { + "fieldname": "section_break_jbas", + "fieldtype": "Section Break" + }, + { + "fieldname": "scheduled_time_tab", + "fieldtype": "Tab Break", + "label": "Scheduled Time" + }, + { + "depends_on": "finished_good", + "fieldname": "manufactured_qty", + "fieldtype": "Float", + "label": "Manufactured Qty", + "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:doc.finished_good", + "fieldname": "skip_material_transfer", + "fieldtype": "Check", + "label": "Skip Material Transfer to WIP" + }, + { + "default": "0", + "depends_on": "eval:doc.finished_good && doc.skip_material_transfer === 1", + "fieldname": "backflush_from_wip_warehouse", + "fieldtype": "Check", + "label": "Backflush Materials From WIP Warehouse" + }, + { + "default": "0", + "fieldname": "is_paused", + "fieldtype": "Check", + "label": "Is Paused", + "read_only": 1 } ], "is_submittable": 1, "links": [], - "modified": "2024-03-27 13:09:56.634418", + "modified": "2024-05-26 17:44:18.324743", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", @@ -564,6 +667,7 @@ "write": 1 } ], + "show_preview_popup": 1, "sort_field": "creation", "sort_order": "DESC", "states": [], diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index c565c910c4e2..100ca45a9f09 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -9,7 +9,7 @@ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.query_builder import Criterion -from frappe.query_builder.functions import IfNull, Max, Min +from frappe.query_builder.functions import IfNull, Max, Min, Sum from frappe.utils import ( add_days, add_to_date, @@ -21,13 +21,15 @@ getdate, time_diff, time_diff_in_hours, - time_diff_in_seconds, ) from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import ( get_mins_between_operations, ) from erpnext.manufacturing.doctype.workstation_type.workstation_type import get_workstations +from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( + get_subcontracting_boms_for_finished_goods, +) class OverlapError(frappe.ValidationError): @@ -64,14 +66,13 @@ class JobCard(Document): from erpnext.manufacturing.doctype.job_card_scheduled_time.job_card_scheduled_time import ( JobCardScheduledTime, ) - from erpnext.manufacturing.doctype.job_card_scrap_item.job_card_scrap_item import ( - JobCardScrapItem, - ) + from erpnext.manufacturing.doctype.job_card_scrap_item.job_card_scrap_item import JobCardScrapItem from erpnext.manufacturing.doctype.job_card_time_log.job_card_time_log import JobCardTimeLog actual_end_date: DF.Datetime | None actual_start_date: DF.Datetime | None amended_from: DF.Link | None + backflush_from_wip_warehouse: DF.Check barcode: DF.Barcode | None batch_no: DF.Link | None bom_no: DF.Link | None @@ -80,18 +81,22 @@ class JobCard(Document): employee: DF.TableMultiSelect[JobCardTimeLog] expected_end_date: DF.Datetime | None expected_start_date: DF.Datetime | None + finished_good: DF.Link | None for_job_card: DF.Link | None for_operation: DF.Link | None for_quantity: DF.Float hour_rate: DF.Currency is_corrective_job_card: DF.Check + is_paused: DF.Check + is_subcontracted: DF.Check item_name: DF.ReadOnly | None items: DF.Table[JobCardItem] - job_started: DF.Check + manufactured_qty: DF.Float naming_series: DF.Literal["PO-JOB.#####"] operation: DF.Link operation_id: DF.Data | None - operation_row_number: DF.Literal + operation_row_id: DF.Int + operation_row_number: DF.Literal[None] posting_date: DF.Date | None process_loss_qty: DF.Float production_item: DF.Link | None @@ -102,9 +107,12 @@ class JobCard(Document): requested_qty: DF.Float scheduled_time_logs: DF.Table[JobCardScheduledTime] scrap_items: DF.Table[JobCardScrapItem] + semi_fg_bom: DF.Link | None sequence_id: DF.Int serial_and_batch_bundle: DF.Link | None serial_no: DF.SmallText | None + skip_material_transfer: DF.Check + source_warehouse: DF.Link | None started_time: DF.Datetime | None status: DF.Literal[ "Open", @@ -116,12 +124,13 @@ class JobCard(Document): "Completed", ] sub_operations: DF.Table[JobCardOperation] + target_warehouse: DF.Link | None time_logs: DF.Table[JobCardTimeLog] time_required: DF.Float total_completed_qty: DF.Float total_time_in_mins: DF.Float transferred_qty: DF.Float - wip_warehouse: DF.Link + wip_warehouse: DF.Link | None work_order: DF.Link workstation: DF.Link workstation_type: DF.Link | None @@ -141,6 +150,7 @@ def before_validate(self): def validate(self): self.validate_time_logs() + self.validate_on_hold() self.set_status() self.validate_operation_id() self.validate_sequence_id() @@ -151,6 +161,31 @@ def validate(self): def on_update(self): self.validate_job_card_qty() + def validate_on_hold(self): + if self.is_paused and not self.time_logs: + self.is_paused = 0 + + def set_manufactured_qty(self): + table_name = "Stock Entry" + if self.is_subcontracted: + table_name = "Subcontracting Receipt Item" + + table = frappe.qb.DocType(table_name) + query = frappe.qb.from_(table).where((table.job_card == self.name) & (table.docstatus == 1)) + + if self.is_subcontracted: + query = query.select(Sum(table.qty)) + else: + query = query.select(Sum(table.fg_completed_qty)) + query = query.where(table.purpose == "Manufacture") + + qty = query.run()[0][0] or 0.0 + self.manufactured_qty = flt(qty) + self.db_set("manufactured_qty", self.manufactured_qty) + + self.update_semi_finished_good_details() + self.set_status(update_status=True) + def validate_job_card_qty(self): if not (self.operation_id and self.work_order): return @@ -511,13 +546,14 @@ def add_time_log(self, args): if self.time_logs and len(self.time_logs) > 0: last_row = self.time_logs[-1] - self.reset_timer_value(args) if last_row and args.get("complete_time"): for row in self.time_logs: if not row.to_time: - row.update( + to_time = get_datetime(args.get("complete_time")) + row.db_set( { - "to_time": get_datetime(args.get("complete_time")), + "to_time": to_time, + "time_in_mins": time_diff_in_minutes(to_time, row.from_time), "operation": args.get("sub_operation"), "completed_qty": (args.get("completed_qty") if last_row.idx == row.idx else 0.0), } @@ -538,35 +574,17 @@ def add_time_log(self, args): else: self.add_start_time_log(new_args) - if not self.employee and employees: - self.set_employees(employees) - - if self.status == "On Hold": - self.current_time = time_diff_in_seconds(last_row.to_time, last_row.from_time) - - self.save() - def add_start_time_log(self, args): - self.append("time_logs", args) + if args.from_time and args.to_time: + args.time_in_mins = time_diff_in_minutes(args.to_time, args.from_time) + + row = self.append("time_logs", args) + row.db_update() def set_employees(self, employees): for name in employees: self.append("employee", {"employee": name.get("employee"), "completed_qty": 0.0}) - - def reset_timer_value(self, args): - self.started_time = None - - if args.get("status") in ["Work In Progress", "Complete"]: - self.current_time = 0.0 - - if args.get("status") == "Work In Progress": - self.started_time = get_datetime(args.get("start_time")) - - if args.get("status") == "Resume Job": - args["status"] = "Work In Progress" - - if args.get("status"): - self.status = args.get("status") + self.save() def update_sub_operation_status(self): if not (self.sub_operations and self.time_logs): @@ -628,23 +646,25 @@ def get_required_items(self): return doc = frappe.get_doc("Work Order", self.get("work_order")) - if doc.transfer_material_against == "Work Order" or doc.skip_transfer: + if not doc.track_semi_finished_goods and ( + doc.transfer_material_against == "Work Order" or doc.skip_transfer + ): return for d in doc.required_items: - if not d.operation: + if not d.operation and not d.operation_row_id: frappe.throw( _("Row {0} : Operation is required against the raw material item {1}").format( d.idx, d.item_code ) ) - if self.get("operation") == d.operation: + if self.get("operation") == d.operation or self.operation_row_id == d.operation_row_id: self.append( "items", { "item_code": d.item_code, - "source_warehouse": d.source_warehouse, + "source_warehouse": self.source_warehouse or d.source_warehouse, "uom": frappe.db.get_value("Item", d.item_code, "stock_uom"), "item_name": d.item_name, "description": d.description, @@ -669,7 +689,7 @@ def on_cancel(self): self.set_transferred_qty() def validate_transfer_qty(self): - if self.items and self.transferred_qty < self.for_quantity: + if not self.finished_good and self.items and self.transferred_qty < self.for_quantity: frappe.throw( _( "Materials needs to be transferred to the work in progress warehouse for the job card {0}" @@ -677,6 +697,9 @@ def validate_transfer_qty(self): ) def validate_job_card(self): + if self.finished_good: + return + if self.work_order and frappe.get_cached_value("Work Order", self.work_order, "status") == "Stopped": frappe.throw( _("Transaction not allowed against stopped Work Order {0}").format( @@ -745,6 +768,9 @@ def set_process_loss(self): ) def update_work_order(self): + if self.finished_good: + return + if not self.work_order: return @@ -756,7 +782,6 @@ def update_work_order(self): return for_quantity, time_in_mins, process_loss_qty = 0, 0, 0 - _from_time_list, _to_time_list = [], [] data = self.get_current_operation_data() if data and len(data) > 0: @@ -773,6 +798,20 @@ def update_work_order(self): self.validate_produced_quantity(for_quantity, process_loss_qty, wo) self.update_work_order_data(for_quantity, process_loss_qty, time_in_mins, wo) + def update_semi_finished_good_details(self): + if self.operation_id: + frappe.db.set_value( + "Work Order Operation", self.operation_id, "completed_qty", self.manufactured_qty + ) + if ( + self.finished_good + and frappe.get_cached_value("Work Order", self.work_order, "production_item") + == self.finished_good + ): + _wo_doc = frappe.get_doc("Work Order", self.work_order) + _wo_doc.db_set("produced_qty", self.manufactured_qty) + _wo_doc.db_set("status", _wo_doc.get_status()) + def update_corrective_in_work_order(self, wo): wo.corrective_operation_cost = 0.0 for row in frappe.get_all( @@ -913,64 +952,71 @@ def _validate_over_transfer(row, transferred_qty): frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty)) def set_transferred_qty(self, update_status=False): - "Set total FG Qty in Job Card for which RM was transferred." - if not self.items: - self.transferred_qty = self.for_quantity if self.docstatus == 1 else 0 + from frappe.query_builder.functions import Sum - doc = frappe.get_doc("Work Order", self.get("work_order")) - if doc.transfer_material_against == "Work Order" or doc.skip_transfer: - return + stock_entry = frappe.qb.DocType("Stock Entry") - if self.items: - # sum of 'For Quantity' of Stock Entries against JC - self.transferred_qty = ( - frappe.db.get_value( - "Stock Entry", - { - "job_card": self.name, - "work_order": self.work_order, - "docstatus": 1, - "purpose": "Material Transfer for Manufacture", - }, - "sum(fg_completed_qty)", - ) - or 0 + query = ( + frappe.qb.from_(stock_entry) + .select(Sum(stock_entry.fg_completed_qty)) + .where( + (stock_entry.job_card == self.name) + & (stock_entry.docstatus == 1) + & (stock_entry.purpose == "Material Transfer for Manufacture") ) + .groupby(stock_entry.job_card) + ) - self.db_set("transferred_qty", self.transferred_qty) - + query = query.run() qty = 0 - if self.work_order: - doc = frappe.get_doc("Work Order", self.work_order) - if doc.transfer_material_against == "Job Card" and not doc.skip_transfer: - completed = True - for d in doc.operations: - if d.status != "Completed": - completed = False - break - - if completed: - job_cards = frappe.get_all( - "Job Card", - filters={"work_order": self.work_order, "docstatus": ("!=", 2)}, - fields="sum(transferred_qty) as qty", - group_by="operation_id", - ) - if job_cards: - qty = min(d.qty for d in job_cards) - - doc.db_set("material_transferred_for_manufacturing", qty) + if query and query[0][0]: + qty = flt(query[0][0]) + self.db_set("transferred_qty", qty) self.set_status(update_status) - def set_status(self, update_status=False): - if self.status == "On Hold" and self.docstatus == 0: - return + if self.work_order and not frappe.get_cached_value( + "Work Order", self.work_order, "track_semi_finished_goods" + ): + self.set_transferred_qty_in_work_order() + + def set_transferred_qty_in_work_order(self): + doc = frappe.get_doc("Work Order", self.work_order) + + qty = 0.0 + if doc.transfer_material_against == "Job Card" and not doc.skip_transfer: + completed = True + for d in doc.operations: + if d.status != "Completed": + completed = False + break + + if completed: + job_cards = frappe.get_all( + "Job Card", + filters={"work_order": self.work_order, "docstatus": ("!=", 2)}, + fields="sum(transferred_qty) as qty", + group_by="operation_id", + ) + + if job_cards: + qty = min(d.qty for d in job_cards) + + doc.db_set("material_transferred_for_manufacturing", qty) + def set_status(self, update_status=False): self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0] + if self.finished_good and self.docstatus == 1: + if self.manufactured_qty >= self.for_quantity: + self.status = "Completed" + elif self.transferred_qty > 0 or self.skip_material_transfer: + self.status = "Work In Progress" - if self.docstatus < 2: + if self.docstatus == 0 and self.time_logs: + self.status = "Work In Progress" + + if not self.finished_good and self.docstatus < 2: if flt(self.for_quantity) <= flt(self.transferred_qty): self.status = "Material Transferred" @@ -980,16 +1026,14 @@ def set_status(self, update_status=False): if self.docstatus == 1 and (self.for_quantity <= self.total_completed_qty or not self.items): self.status = "Completed" + if self.is_paused: + self.status = "On Hold" + if update_status: self.db_set("status", self.status) - if self.status in ["Completed", "Work In Progress"]: - status = { - "Completed": "Off", - "Work In Progress": "Production", - }.get(self.status) - - self.update_status_in_workstation(status) + if self.workstation: + self.update_workstation_status() def set_wip_warehouse(self): if not self.wip_warehouse: @@ -1012,7 +1056,30 @@ def validate_operation_id(self): OperationMismatchError, ) + @frappe.whitelist() + def pause_job(self, **kwargs): + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + self.db_set("is_paused", 1) + self.add_time_logs(to_time=kwargs.end_time, completed_qty=0.0, employees=self.employee) + + @frappe.whitelist() + def resume_job(self, **kwargs): + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + self.db_set("is_paused", 0) + self.add_time_logs( + from_time=kwargs.start_time, + employees=self.employee, + completed_qty=0.0, + ) + def validate_sequence_id(self): + if self.is_new(): + return + if self.is_corrective_job_card: return @@ -1038,6 +1105,14 @@ def validate_sequence_id(self): ) for row in data: + if not row.completed_qty: + frappe.throw( + _("{0}, complete the operation {1} before the operation {2}.").format( + message, bold(row.operation), bold(self.operation) + ), + OperationSequenceError, + ) + if row.status != "Completed" and row.completed_qty < current_operation_qty: frappe.throw( _("{0}, complete the operation {1} before the operation {2}.").format( @@ -1075,16 +1150,173 @@ def update_status_in_workstation(self, status): frappe.db.set_value("Workstation", self.workstation, "status", status) + def add_time_logs(self, **kwargs): + row = None + kwargs = frappe._dict(kwargs) + + update_status = False + for employee in kwargs.employees: + kwargs.employee = employee.get("employee") + if kwargs.from_time and not kwargs.to_time: + row = self.append("time_logs", kwargs) + row.db_update() + self.db_set("status", "Work In Progress") + else: + update_status = True + for row in self.time_logs: + if row.to_time or row.employee != kwargs.employee: + continue + + row.to_time = kwargs.to_time + row.time_in_mins = time_diff_in_minutes(row.to_time, row.from_time) + + if kwargs.employees[-1].get("employee") == row.employee: + row.completed_qty = kwargs.completed_qty + + row.db_update() + + self.set_status(update_status=update_status) + + if not self.employee and kwargs.employees: + self.set_employees(kwargs.employees) + + def update_workstation_status(self): + status_map = { + "Open": "Off", + "Work In Progress": "Production", + "Completed": "Off", + "On Hold": "Idle", + } + + job_cards = frappe.get_all( + "Job Card", + fields=["name", "status"], + filters={"workstation": self.workstation, "docstatus": 0, "status": ("!=", "Completed")}, + order_by="status desc", + ) + + if not job_cards: + frappe.db.set_value("Workstation", self.workstation, "status", "Off") + + for row in job_cards: + frappe.db.set_value("Workstation", self.workstation, "status", status_map.get(row.status)) + return + + @frappe.whitelist() + def start_timer(self, **kwargs): + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + if isinstance(kwargs.employees, str): + kwargs.employees = [{"employee": kwargs.employees}] + + if kwargs.start_time: + self.add_time_logs(from_time=kwargs.start_time, employees=kwargs.employees) + + @frappe.whitelist() + def complete_job_card(self, **kwargs): + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + if kwargs.end_time: + self.add_time_logs(to_time=kwargs.end_time, completed_qty=kwargs.qty, employees=self.employee) + self.save() + + if kwargs.auto_submit: + self.submit() + self.make_stock_entry_for_semi_fg_item(kwargs.auto_submit) + frappe.msgprint( + _("Job Card {0} has been completed").format(get_link_to_form("Job Card", self.name)) + ) + + @frappe.whitelist() + def make_stock_entry_for_semi_fg_item(self, auto_submit=False): + from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry + + ste = ManufactureEntry( + { + "for_quantity": self.for_quantity - self.manufactured_qty, + "job_card": self.name, + "skip_material_transfer": self.skip_material_transfer, + "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, + "work_order": self.work_order, + "purpose": "Manufacture", + "production_item": self.finished_good, + "company": self.company, + "wip_warehouse": self.wip_warehouse, + "fg_warehouse": self.target_warehouse, + "bom_no": self.semi_fg_bom, + "project": frappe.db.get_value("Work Order", self.work_order, "project"), + } + ) + + ste.make_stock_entry() + ste.stock_entry.flags.ignore_mandatory = True + ste.stock_entry.save() + + if auto_submit: + ste.stock_entry.submit() + + frappe.msgprint( + _("Stock Entry {0} has created").format(get_link_to_form("Stock Entry", ste.stock_entry.name)) + ) + + return ste.stock_entry.as_dict() + + +@frappe.whitelist() +def make_subcontracting_po(source_name, target_doc=None): + def set_missing_values(source, target): + _item_details = get_subcontracting_boms_for_finished_goods(source.finished_good) + + pending_qty = source.for_quantity - source.manufactured_qty + service_item_qty = flt(_item_details.service_item_qty) or 1.0 + fg_item_qty = flt(_item_details.finished_good_qty) or 1.0 + + target.is_subcontracted = 1 + target.supplier_warehouse = source.wip_warehouse + target.append( + "items", + { + "item_code": _item_details.service_item, + "fg_item": source.finished_good, + "uom": _item_details.service_item_uom, + "stock_uom": _item_details.service_item_uom, + "conversion_factor": _item_details.conversion_factor or 1, + "item_name": _item_details.service_item, + "qty": pending_qty * service_item_qty / fg_item_qty, + "fg_item_qty": pending_qty, + "job_card": source.name, + "bom": source.semi_fg_bom, + "warehouse": source.target_warehouse, + }, + ) + + doclist = get_mapped_doc( + "Job Card", + source_name, + { + "Job Card": { + "doctype": "Purchase Order", + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + @frappe.whitelist() -def make_time_log(args): - if isinstance(args, str): - args = json.loads(args) +def make_time_log(kwargs): + if isinstance(kwargs, str): + kwargs = json.loads(kwargs) - args = frappe._dict(args) - doc = frappe.get_doc("Job Card", args.job_card_id) + kwargs = frappe._dict(kwargs) + doc = frappe.get_doc("Job Card", kwargs.job_card_id) doc.validate_sequence_id() - doc.add_time_log(args) + doc.add_time_log(kwargs) + doc.set_status(update_status=True) @frappe.whitelist() @@ -1271,7 +1503,6 @@ def set_missing_values(source, target): target.set("sub_operations", []) target.set_sub_operations() target.get_required_items() - target.validate_time_logs() doclist = get_mapped_doc( "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card_dashboard.py b/erpnext/manufacturing/doctype/job_card/job_card_dashboard.py index 14c1f36d0dcf..1718ea547955 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card_dashboard.py +++ b/erpnext/manufacturing/doctype/job_card/job_card_dashboard.py @@ -7,6 +7,7 @@ def get_data(): "non_standard_fieldnames": {"Quality Inspection": "reference_name"}, "transactions": [ {"label": _("Transactions"), "items": ["Material Request", "Stock Entry"]}, + {"label": _("Subcontracting"), "items": ["Purchase Order", "Subcontracting Order"]}, {"label": _("Reference"), "items": ["Quality Inspection"]}, ], } diff --git a/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json b/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json index 884e83e0f262..aed78636aa5e 100644 --- a/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json +++ b/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json @@ -15,12 +15,14 @@ ], "fields": [ { + "allow_on_submit": 1, "fieldname": "from_time", "fieldtype": "Datetime", "in_list_view": 1, "label": "From Time" }, { + "allow_on_submit": 1, "fieldname": "to_time", "fieldtype": "Datetime", "in_list_view": 1, @@ -31,6 +33,7 @@ "fieldtype": "Column Break" }, { + "allow_on_submit": 1, "fieldname": "time_in_mins", "fieldtype": "Float", "in_list_view": 1, @@ -38,6 +41,7 @@ "read_only": 1 }, { + "allow_on_submit": 1, "default": "0", "fieldname": "completed_qty", "fieldtype": "Float", @@ -63,7 +67,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-05-21 12:40:55.765860", + "modified": "2024-05-21 12:41:55.765860", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Time Log", diff --git a/erpnext/manufacturing/doctype/operation/operation.json b/erpnext/manufacturing/doctype/operation/operation.json index ba531164e08a..c712a9f140d1 100644 --- a/erpnext/manufacturing/doctype/operation/operation.json +++ b/erpnext/manufacturing/doctype/operation/operation.json @@ -40,6 +40,7 @@ { "fieldname": "description", "fieldtype": "Text", + "in_preview": 1, "label": "Description" }, { @@ -104,7 +105,7 @@ "icon": "fa fa-wrench", "index_web_pages_for_search": 1, "links": [], - "modified": "2024-03-27 13:10:06.841479", + "modified": "2024-05-26 17:59:44.338741", "modified_by": "Administrator", "module": "Manufacturing", "name": "Operation", @@ -134,6 +135,7 @@ } ], "quick_entry": 1, + "show_preview_popup": 1, "sort_field": "creation", "sort_order": "DESC", "states": [], diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 1da33f0ad9b1..8a806c179e9d 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -149,13 +149,7 @@ frappe.ui.form.on("Work Order", { frm.doc.operations && frm.doc.operations.length ) { - const not_completed = frm.doc.operations.filter((d) => { - if (d.status != "Completed") { - return true; - } - }); - - if (not_completed && not_completed.length) { + if (frm.doc.__onload?.show_create_job_card_button) { frm.add_custom_button(__("Create Job Card"), () => { frm.trigger("make_job_card"); }).addClass("btn-primary"); @@ -277,6 +271,18 @@ frappe.ui.form.on("Work Order", { label: __("Sequence Id"), read_only: 1, }, + { + fieldtype: "Check", + fieldname: "skip_material_transfer", + label: __("Skip Material Transfer"), + read_only: 1, + }, + { + fieldtype: "Check", + fieldname: "backflush_from_wip_warehouse", + label: __("Backflush Materials From WIP Warehouse"), + read_only: 1, + }, ], data: operations_data, in_place_edit: true, @@ -317,6 +323,8 @@ frappe.ui.form.on("Work Order", { qty: pending_qty, pending_qty: pending_qty, sequence_id: data.sequence_id, + skip_material_transfer: data.skip_material_transfer, + backflush_from_wip_warehouse: data.backflush_from_wip_warehouse, }); } } @@ -615,22 +623,25 @@ erpnext.work_order = { ); } - const show_start_btn = - frm.doc.skip_transfer || frm.doc.transfer_material_against == "Job Card" ? 0 : 1; + if (!frm.doc.track_semi_finished_goods) { + const show_start_btn = + frm.doc.skip_transfer || frm.doc.transfer_material_against == "Job Card" ? 0 : 1; - if (show_start_btn) { - let pending_to_transfer = frm.doc.required_items.some( - (item) => flt(item.transferred_qty) < flt(item.required_qty) - ); - if (pending_to_transfer && frm.doc.status != "Stopped") { - frm.has_start_btn = true; - frm.add_custom_button(__("Create Pick List"), function () { - erpnext.work_order.create_pick_list(frm); - }); - var start_btn = frm.add_custom_button(__("Start"), function () { - erpnext.work_order.make_se(frm, "Material Transfer for Manufacture"); - }); - start_btn.addClass("btn-primary"); + if (show_start_btn) { + let pending_to_transfer = frm.doc.required_items.some( + (item) => flt(item.transferred_qty) < flt(item.required_qty) + ); + if (pending_to_transfer && frm.doc.status != "Stopped") { + frm.has_start_btn = true; + frm.add_custom_button(__("Create Pick List"), function () { + erpnext.work_order.create_pick_list(frm); + }); + + var start_btn = frm.add_custom_button(__("Start"), function () { + erpnext.work_order.make_se(frm, "Material Transfer for Manufacture"); + }); + start_btn.addClass("btn-primary"); + } } } diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 36b992d0de5e..e564af27d95d 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -22,6 +22,16 @@ "produced_qty", "process_loss_qty", "project", + "track_semi_finished_goods", + "warehouses", + "source_warehouse", + "wip_warehouse", + "column_break_12", + "fg_warehouse", + "scrap_warehouse", + "operations_section", + "transfer_material_against", + "operations", "section_break_ndpq", "required_items", "work_order_configuration", @@ -32,22 +42,11 @@ "skip_transfer", "from_wip_warehouse", "update_consumed_material_cost_in_project", - "warehouses", - "source_warehouse", - "wip_warehouse", - "column_break_12", - "fg_warehouse", - "scrap_warehouse", "serial_no_and_batch_for_finished_good_section", "has_serial_no", "has_batch_no", "column_break_18", "batch_size", - "required_items_section", - "materials_and_operations_tab", - "operations_section", - "transfer_material_against", - "operations", "time", "planned_start_date", "planned_end_date", @@ -196,7 +195,7 @@ }, { "default": "0", - "depends_on": "eval:doc.docstatus==1 && doc.skip_transfer==0", + "depends_on": "eval:doc.docstatus==1 && doc.skip_transfer==0 && doc.track_semi_finished_goods === 0", "fieldname": "material_transferred_for_manufacturing", "fieldtype": "Float", "label": "Material Transferred for Manufacturing", @@ -248,7 +247,7 @@ "fieldname": "wip_warehouse", "fieldtype": "Link", "label": "Work-in-Progress Warehouse", - "mandatory_depends_on": "eval:!doc.skip_transfer || doc.from_wip_warehouse", + "mandatory_depends_on": "eval:(!doc.skip_transfer || doc.from_wip_warehouse) && !doc.track_semi_finished_goods", "options": "Warehouse" }, { @@ -256,8 +255,7 @@ "fieldname": "fg_warehouse", "fieldtype": "Link", "label": "Target Warehouse", - "options": "Warehouse", - "reqd": 1 + "options": "Warehouse" }, { "fieldname": "column_break_12", @@ -270,15 +268,9 @@ "label": "Scrap Warehouse", "options": "Warehouse" }, - { - "fieldname": "required_items_section", - "fieldtype": "Section Break", - "label": "Required Items" - }, { "fieldname": "required_items", "fieldtype": "Table", - "label": "Required Items", "no_copy": 1, "options": "Work Order Item", "print_hide": 1 @@ -336,7 +328,7 @@ "options": "fa fa-wrench" }, { - "depends_on": "operations", + "depends_on": "eval: doc.operations?.length && doc.track_semi_finished_goods === 0", "fetch_from": "bom_no.transfer_material_against", "fetch_if_empty": 1, "fieldname": "transfer_material_against", @@ -579,13 +571,19 @@ "label": "Configuration" }, { - "fieldname": "materials_and_operations_tab", - "fieldtype": "Tab Break", - "label": "Operations" + "collapsible": 1, + "collapsible_depends_on": "eval:!doc.operations?.length", + "fieldname": "section_break_ndpq", + "fieldtype": "Section Break", + "label": "Required Items" }, { - "fieldname": "section_break_ndpq", - "fieldtype": "Section Break" + "default": "0", + "fetch_from": "bom_no.track_semi_finished_goods", + "fieldname": "track_semi_finished_goods", + "fieldtype": "Check", + "label": "Track Semi Finished Goods", + "read_only": 1 } ], "icon": "fa fa-cogs", @@ -593,7 +591,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2024-03-27 13:11:00.129434", + "modified": "2024-03-27 13:13:00.129434", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index b5c6cd9330f6..b4d1e9d26935 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -143,6 +143,24 @@ def onload(self): self.set_onload("material_consumption", ms.material_consumption) self.set_onload("backflush_raw_materials_based_on", ms.backflush_raw_materials_based_on) self.set_onload("overproduction_percentage", ms.overproduction_percentage_for_work_order) + self.set_onload("show_create_job_card_button", self.show_create_job_card_button()) + + def show_create_job_card_button(self): + operation_details = frappe._dict( + frappe.get_all( + "Job Card", + fields=["operation", "for_quantity"], + filters={"docstatus": ("<", 2), "work_order": self.name}, + as_list=1, + ) + ) + + for d in self.operations: + job_card_qty = self.qty - flt(operation_details.get(d.operation)) + if job_card_qty > 0: + return True + + return False def validate(self): self.validate_production_item() @@ -422,15 +440,20 @@ def update_production_plan_status(self): self.update_status() production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item) - def before_submit(self): - self.create_serial_no_batch_no() + def validate_warehouse(self): + if self.track_semi_finished_goods: + return - def on_submit(self): if not self.wip_warehouse and not self.skip_transfer: frappe.throw(_("Work-in-Progress Warehouse is required before Submit")) if not self.fg_warehouse: - frappe.throw(_("For Warehouse is required before Submit")) + frappe.throw(_("Target Warehouse is required before Submit")) + + def before_submit(self): + self.create_serial_no_batch_no() + def on_submit(self): + self.validate_warehouse() if self.production_plan and frappe.db.exists( "Production Plan Item Reference", {"parent": self.production_plan} ): @@ -667,6 +690,9 @@ def validate_cancel(self): ) def update_planned_qty(self): + if self.track_semi_finished_goods: + return + from erpnext.manufacturing.doctype.production_plan.production_plan import ( get_reserved_qty_for_sub_assembly, ) @@ -811,13 +837,21 @@ def _get_operations(bom_no, qty=1): "description", "workstation", "idx", + "finished_good", + "is_subcontracted", + "wip_warehouse", + "source_warehouse", + "fg_warehouse", "workstation_type", "base_hour_rate as hour_rate", "time_in_mins", "parent as bom", + "bom_no", "batch_size", "sequence_id", "fixed_time", + "skip_material_transfer", + "backflush_from_wip_warehouse", ], order_by="idx", ) @@ -827,6 +861,9 @@ def _get_operations(bom_no, qty=1): d.time_in_mins = flt(d.time_in_mins) * flt(qty) d.status = "Pending" + if self.track_semi_finished_goods and not d.sequence_id: + d.sequence_id = d.idx + return data self.set("operations", []) @@ -1084,6 +1121,7 @@ def set_required_items(self, reset_only_qty=False): "required_qty": item.qty, "source_warehouse": item.source_warehouse or item.default_warehouse, "include_item_in_manufacturing": item.include_item_in_manufacturing, + "operation_row_id": item.operation_row_id, }, ) @@ -1284,6 +1322,7 @@ def make_work_order(bom_no, item, qty=0, project=None, variant_items=None): item_details = get_item_details(item, project) wo_doc = frappe.new_doc("Work Order") + wo_doc.track_semi_finished_goods = frappe.db.get_value("BOM", bom_no, "track_semi_finished_goods") wo_doc.production_item = item wo_doc.update(item_details) wo_doc.bom_no = bom_no @@ -1450,6 +1489,8 @@ def make_job_card(work_order, operations): work_order = frappe.get_doc("Work Order", work_order) for row in operations: row = frappe._dict(row) + row.update(get_operation_details(row.name, work_order)) + validate_operation_data(row) qty = row.get("qty") while qty > 0: @@ -1458,6 +1499,21 @@ def make_job_card(work_order, operations): create_job_card(work_order, row, auto_create=True) +def get_operation_details(name, work_order): + for row in work_order.operations: + if row.name == name: + return { + "workstation": row.workstation, + "workstation_type": row.workstation_type, + "source_warehouse": row.source_warehouse, + "fg_warehouse": row.fg_warehouse, + "wip_warehouse": row.wip_warehouse, + "finished_good": row.finished_good, + "bom_no": row.get("bom_no"), + "is_subcontracted": row.get("is_subcontracted"), + } + + @frappe.whitelist() def close_work_order(work_order, status): if not frappe.has_permission("Work Order", "write"): @@ -1558,6 +1614,7 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create "workstation_type": row.get("workstation_type"), "operation": row.get("operation"), "workstation": row.get("workstation"), + "operation_row_id": cint(row.idx), "posting_date": nowdate(), "for_quantity": row.job_card_qty or work_order.get("qty", 0), "operation_id": row.get("name"), @@ -1565,13 +1622,22 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create "project": work_order.project, "company": work_order.company, "sequence_id": row.get("sequence_id"), - "wip_warehouse": work_order.wip_warehouse, "hour_rate": row.get("hour_rate"), "serial_no": row.get("serial_no"), + "source_warehouse": row.get("source_warehouse"), + "target_warehouse": row.get("fg_warehouse"), + "wip_warehouse": work_order.wip_warehouse or row.get("wip_warehouse"), + "skip_material_transfer": row.get("skip_material_transfer"), + "backflush_from_wip_warehouse": row.get("backflush_from_wip_warehouse"), + "finished_good": row.get("finished_good"), + "semi_fg_bom": row.get("bom_no"), + "is_subcontracted": row.get("is_subcontracted"), } ) - if work_order.transfer_material_against == "Job Card" and not work_order.skip_transfer: + if work_order.track_semi_finished_goods or ( + work_order.transfer_material_against == "Job Card" and not work_order.skip_transfer + ): doc.get_required_items() if auto_create: diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json index 0d3500a96c18..abb73f0ccc80 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json @@ -8,6 +8,7 @@ "operation", "item_code", "source_warehouse", + "operation_row_id", "column_break_3", "item_name", "description", @@ -138,11 +139,17 @@ "in_list_view": 1, "label": "Returned Qty ", "read_only": 1 + }, + { + "fieldname": "operation_row_id", + "fieldtype": "Int", + "label": "Operation Row Id", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2024-03-27 13:11:00.429838", + "modified": "2024-03-27 13:12:00.429838", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Item", diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json index 83c27e8c2f71..9146122a858f 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json @@ -15,6 +15,16 @@ "workstation_type", "workstation", "sequence_id", + "section_break_insy", + "bom_no", + "finished_good", + "is_subcontracted", + "skip_material_transfer", + "backflush_from_wip_warehouse", + "column_break_vjih", + "source_warehouse", + "wip_warehouse", + "fg_warehouse", "section_break_10", "description", "estimated_time_and_cost", @@ -52,7 +62,6 @@ "columns": 2, "fieldname": "bom", "fieldtype": "Link", - "in_list_view": 1, "label": "BOM", "no_copy": 1, "options": "BOM", @@ -66,11 +75,10 @@ "oldfieldtype": "Text" }, { - "columns": 2, + "columns": 1, "description": "Operation completed for how many finished goods?", "fieldname": "completed_qty", "fieldtype": "Float", - "in_list_view": 1, "label": "Completed Qty", "no_copy": 1 }, @@ -213,16 +221,83 @@ "columns": 2, "fieldname": "process_loss_qty", "fieldtype": "Float", - "in_list_view": 1, "label": "Process Loss Qty", "no_copy": 1, "read_only": 1 + }, + { + "depends_on": "eval:parent.track_semi_finished_goods === 1", + "fieldname": "section_break_insy", + "fieldtype": "Section Break" + }, + { + "fieldname": "bom_no", + "fieldtype": "Link", + "label": "BOM No (For Semi-FG)", + "options": "BOM", + "read_only": 1 + }, + { + "fieldname": "column_break_vjih", + "fieldtype": "Column Break" + }, + { + "fieldname": "finished_good", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Semi FG / FG", + "options": "Item", + "read_only": 1 + }, + { + "columns": 1, + "fieldname": "wip_warehouse", + "fieldtype": "Link", + "label": "WIP WH", + "options": "Warehouse" + }, + { + "columns": 2, + "fieldname": "fg_warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "FG Warehouse", + "options": "Warehouse" + }, + { + "columns": 2, + "fieldname": "source_warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Source Warehouse", + "options": "Warehouse" + }, + { + "default": "0", + "fieldname": "is_subcontracted", + "fieldtype": "Check", + "label": "Is Subcontracted", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "skip_material_transfer", + "fieldtype": "Check", + "label": "Skip Material Transfer", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "backflush_from_wip_warehouse", + "fieldtype": "Check", + "label": "Backflush Materials From WIP Warehouse", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:11:00.595376", + "modified": "2024-05-26 15:57:17.958543", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Operation", diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py index 5bd3ab1b21f7..fb8b3feb4dd7 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py @@ -18,11 +18,16 @@ class WorkOrderOperation(Document): actual_operating_cost: DF.Currency actual_operation_time: DF.Float actual_start_time: DF.Datetime | None + backflush_from_wip_warehouse: DF.Check batch_size: DF.Float bom: DF.Link | None + bom_no: DF.Link | None completed_qty: DF.Float description: DF.TextEditor | None + fg_warehouse: DF.Link | None + finished_good: DF.Link | None hour_rate: DF.Float + is_subcontracted: DF.Check operation: DF.Link parent: DF.Data parentfield: DF.Data @@ -32,8 +37,11 @@ class WorkOrderOperation(Document): planned_start_time: DF.Datetime | None process_loss_qty: DF.Float sequence_id: DF.Int + skip_material_transfer: DF.Check + source_warehouse: DF.Link | None status: DF.Literal["Pending", "Work in Progress", "Completed"] time_in_mins: DF.Float + wip_warehouse: DF.Link | None workstation: DF.Link | None workstation_type: DF.Link | None # end: auto-generated types diff --git a/erpnext/manufacturing/doctype/workstation/workstation.js b/erpnext/manufacturing/doctype/workstation/workstation.js index c3bf9ef5c8cd..dbcb0113c8fe 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.js +++ b/erpnext/manufacturing/doctype/workstation/workstation.js @@ -105,16 +105,118 @@ class WorkstationDashboard { } render_job_cards() { - let template = frappe.render_template("workstation_job_card", { + this.template = frappe.render_template("workstation_job_card", { data: this.job_cards, }); - this.$wrapper.html(template); + 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_actions = me.$wrapper.find(`.menu-actions[data-job-card='${data.name}']`); + $(me.menu_actions).find(".btn-start").hide(); + $(me.menu_actions).find(".btn-resume").hide(); + $(me.menu_actions).find(".btn-pause").hide(); + $(me.menu_actions).find(".btn-complete").hide(); + + 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_actions).find(".btn-start").show(); + } else if (data.is_paused) { + $(me.menu_actions).find(".btn-resume").show(); + } else if (data.for_quantity - data.manufactured_qty > 0) { + if (!data.is_paused) { + $(me.menu_actions).find(".btn-pause").show(); + } + + $(me.menu_actions).find(".btn-complete").show(); + } + } + }); + } + toggle_job_card() { this.$wrapper.find(".collapse-indicator-job").on("click", (e) => { $(e.currentTarget) @@ -133,133 +235,295 @@ class WorkstationDashboard { } bind_events() { - this.$wrapper.find(".make-material-request").on("click", (e) => { - let job_card = $(e.currentTarget).attr("job-card"); + 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).attr("job-card"); + let job_card = $(e.currentTarget).closest("ul").attr("data-job-card"); this.start_job(job_card); }); + this.$wrapper.find(".btn-pause").on("click", (e) => { + let job_card = $(e.currentTarget).closest("ul").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("ul").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).attr("job-card"); - let pending_qty = flt($(e.currentTarget).attr("pending-qty")); - this.complete_job(job_card, pending_qty); + let job_card = $(e.currentTarget).closest("ul").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( - [ - { - fieldtype: "Datetime", - label: __("Start Time"), - fieldname: "start_time", - reqd: 1, - default: frappe.datetime.now_datetime(), - }, - { - label: __("Operator"), - fieldname: "employee", - fieldtype: "Link", - options: "Employee", - }, - ], + { + fieldname: "qty", + label: __("Completed Quantity"), + fieldtype: "Float", + reqd: 1, + default: flt(for_quantity || 0), + }, (data) => { - this.frm.call({ - method: "start_job", - doc: this.frm.doc, - args: { - job_card: job_card, - from_time: data.start_time, - employee: data.employee, - }, - callback(r) { - if (r.message) { - me.job_cards = [r.message]; - me.prepare_timer(); - me.update_job_card_details(); - me.frm.reload_doc(); - } - }, + 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"), - __("Start Job") + __("Submit") ); } - complete_job(job_card, qty_to_manufacture) { + get_fields_for_employee() { let me = this; - let fields = [ + + return [ { - fieldtype: "Float", - label: __("Completed Quantity"), - fieldname: "qty", - reqd: 1, - default: flt(qty_to_manufacture || 0), + 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(); + } + } + }, }, { + label: __("Start Time"), + fieldname: "start_time", fieldtype: "Datetime", - label: __("End Time"), - fieldname: "end_time", default: frappe.datetime.now_datetime(), }, + { 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, + }, + ], + }, ]; + } - frappe.prompt( - fields, - (data) => { - if (data.qty <= 0) { - frappe.throw(__("Quantity should be greater than 0")); - } + update_job_card(job_card, method, data) { + let me = this; - this.frm.call({ - method: "complete_job", - doc: this.frm.doc, - args: { - job_card: job_card, - qty: data.qty, - to_time: data.end_time, - }, - callback: function (r) { - if (r.message) { - me.job_cards = [r.message]; - me.prepare_timer(); - me.update_job_card_details(); - me.frm.reload_doc(); - } - }, + 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(); }, - __("Enter Value"), - __("Submit") - ); + }); } make_material_request(job_card) { + let me = this; frappe.call({ - method: "erpnext.manufacturing.doctype.job_card.job_card.make_material_request", + method: "erpnext.manufacturing.doctype.workstation.workstation.get_raw_materials", args: { - source_name: job_card, + job_card: job_card, }, callback: (r) => { if (r.message) { - var doc = frappe.model.sync(r.message)[0]; - frappe.set_route("Form", doc.doctype, doc.name); + me.prepare_materials_modal(r.message, job_card, (job_card) => { + frappe.call({ + method: "erpnext.manufacturing.doctype.job_card.job_card.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) { + if (data.time_logs[cint(data.time_logs.length) - 1].to_time || data.is_paused) { this.updateStopwatch(data); } else { this.initialiseTimer(data); @@ -283,23 +547,23 @@ class WorkstationDashboard { [data-name='${data.name}']`); $(job_card_selector).find(".job-card-status").text(data.status); - $(job_card_selector).find(".job-card-status").css("backgroundColor", color_map[data.status]); - if (data.status === "Work In Progress") { - $(job_card_selector).find(".btn-start").addClass("hide"); - $(job_card_selector).find(".btn-complete").removeClass("hide"); - } else if (data.status === "Completed") { - $(job_card_selector).find(".btn-start").addClass("hide"); - $(job_card_selector).find(".btn-complete").addClass("hide"); - } + ["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) { - setInterval(() => { + let timeout = setInterval(() => { data._current_time += 1; this.updateStopwatch(data); }, 1000); + + this.timer_job_cards[data.name] = timeout; } updateStopwatch(data) { diff --git a/erpnext/manufacturing/doctype/workstation/workstation.json b/erpnext/manufacturing/doctype/workstation/workstation.json index 97d7216af81c..7df7cb727a04 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.json +++ b/erpnext/manufacturing/doctype/workstation/workstation.json @@ -9,6 +9,7 @@ "engine": "InnoDB", "field_order": [ "dashboard_tab", + "section_break_mqqv", "workstation_dashboard", "details_tab", "workstation_name", @@ -246,13 +247,18 @@ "fieldname": "workstation_dashboard", "fieldtype": "HTML", "label": "Workstation Dashboard" + }, + { + "fieldname": "section_break_mqqv", + "fieldtype": "Section Break", + "hide_border": 1 } ], "icon": "icon-wrench", "idx": 1, "image_field": "on_status_image", "links": [], - "modified": "2024-03-27 13:11:00.760717", + "modified": "2024-06-01 14:48:47.341354", "modified_by": "Administrator", "module": "Manufacturing", "name": "Workstation", diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index 47cb74228be4..8d2e1e92e56c 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -55,7 +55,14 @@ class Workstation(Document): hour_rate_electricity: DF.Currency hour_rate_labour: DF.Currency hour_rate_rent: DF.Currency + off_status_image: DF.AttachImage | None + on_status_image: DF.AttachImage | None + parts_per_hour: DF.Float + plant_floor: DF.Link | None production_capacity: DF.Int + status: DF.Literal["Production", "Off", "Idle", "Problem", "Maintenance", "Setup"] + total_working_hours: DF.Float + warehouse: DF.Link | None working_hours: DF.Table[WorkstationWorkingHour] workstation_name: DF.Data workstation_type: DF.Link | None @@ -189,7 +196,7 @@ def complete_job(self, job_card, qty, to_time): @frappe.whitelist() -def get_job_cards(workstation): +def get_job_cards(workstation, job_card=None): if frappe.has_permission("Job Card", "read"): jc_data = frappe.get_all( "Job Card", @@ -200,15 +207,22 @@ def get_job_cards(workstation): "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"]], }, @@ -216,64 +230,98 @@ def get_job_cards(workstation): ) job_cards = [row.name for row in jc_data] - raw_materials = get_raw_materials(job_cards) 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: - row.progress_percent = ( - flt(row.total_completed_qty / row.for_quantity * 100, 2) if row.for_quantity else 0 - ) - row.progress_title = _("Total completed quantity: {0}").format(row.total_completed_qty) + item_code = row.finished_good or row.production_item + row.fg_uom = frappe.get_cached_value("Item", item_code, "stock_uom") + row.status_color = get_status_color(row.status) - row.job_card_link = get_link_to_form("Job Card", row.name) + row.job_card_link = f""" + {row.name} + """ + + row.operation_link = f""" + {row.operation} + """ row.work_order_link = get_link_to_form("Work Order", row.work_order) - row.raw_materials = raw_materials.get(row.name, []) 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": "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)", + "Pending": "blue", + "In Process": "yellow", + "Submitted": "blue", + "Open": "gray", + "Closed": "green", + "Work In Progress": "orange", } - return color_map.get(status, "var(--bg-blue)") - + return color_map.get(status, "blue") -def get_raw_materials(job_cards): - raw_materials = {} - data = frappe.get_all( - "Job Card Item", +@frappe.whitelist() +def get_raw_materials(job_card): + raw_materials = frappe.get_all( + "Job Card", fields=[ - "parent", - "item_code", - "item_group", - "uom", - "item_name", - "source_warehouse", - "required_qty", - "transferred_qty", + "`tabJob Card`.`skip_material_transfer`", + "`tabJob Card`.`backflush_from_wip_warehouse`", + "`tabJob Card`.`wip_warehouse`", + "`tabJob Card Item`.`parent`", + "`tabJob Card Item`.`item_code`", + "`tabJob Card Item`.`item_group`", + "`tabJob Card Item`.`uom`", + "`tabJob Card Item`.`item_name`", + "`tabJob Card Item`.`source_warehouse`", + "`tabJob Card Item`.`required_qty`", + "`tabJob Card Item`.`transferred_qty`", ], - filters={"parent": ["in", job_cards]}, + filters={"name": job_card}, ) - for row in data: - raw_materials.setdefault(row.parent, []).append(row) + if not raw_materials: + return [] + + for row in raw_materials: + warehouse = row.source_warehouse + if row.skip_material_transfer and row.backflush_from_wip_warehouse: + warehouse = row.wip_warehouse + + row.stock_qty = ( + frappe.db.get_value( + "Bin", + { + "item_code": row.item_code, + "warehouse": warehouse, + }, + "actual_qty", + ) + or 0.0 + ) + + row.warehouse = warehouse + + row.material_availability_status = 0 + if row.skip_material_transfer and row.stock_qty >= row.required_qty: + row.material_availability_status = 1 + elif row.transferred_qty >= row.required_qty: + row.material_availability_status = 1 return raw_materials @@ -392,20 +440,57 @@ def get_workstations(**kwargs): data = query.run(as_dict=True) color_map = { - "Production": "var(--green-600)", - "Off": "var(--gray-600)", - "Idle": "var(--gray-600)", - "Problem": "var(--red-600)", - "Maintenance": "var(--yellow-600)", - "Setup": "var(--blue-600)", + "Production": "green", + "Off": "gray", + "Idle": "gray", + "Problem": "red", + "Maintenance": "yellow", + "Setup": "blue", } for d in data: d.workstation_name = get_link_to_form("Workstation", d.name) d.status_image = d.on_status_image - d.background_color = color_map.get(d.status, "var(--red-600)") + d.color = color_map.get(d.status, "red") d.workstation_link = get_url_to_form("Workstation", d.name) if d.status != "Production": d.status_image = d.off_status_image return data + + +@frappe.whitelist() +def update_job_card(job_card, method, **kwargs): + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + if kwargs.get("employees"): + kwargs.employees = frappe.parse_json(kwargs.employees) + + if kwargs.qty and isinstance(kwargs.qty, str): + kwargs.qty = flt(kwargs.qty) + + doc = frappe.get_doc("Job Card", job_card) + doc.run_method(method, **kwargs) + + +@frappe.whitelist() +def validate_job_card(job_card, status): + job_card_details = frappe.db.get_value("Job Card", job_card, ["status", "for_quantity"], as_dict=1) + + current_status = job_card_details.status + if current_status != status: + if status == "Open": + frappe.throw( + _("The job card {0} is in {1} state and you cannot start it again.").format( + job_card, current_status + ) + ) + else: + frappe.throw( + _("The job card {0} is in {1} state and you cannot complete.").format( + job_card, current_status + ) + ) + + return job_card_details.for_quantity diff --git a/erpnext/manufacturing/doctype/workstation/workstation_job_card.html b/erpnext/manufacturing/doctype/workstation/workstation_job_card.html index 97707855db0c..847963b70885 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation_job_card.html +++ b/erpnext/manufacturing/doctype/workstation/workstation_job_card.html @@ -1,6 +1,6 @@ -
+
+ +
+ {% $.each(data, (idx, d) => { %}