From 152a03aeade4ab012e5a69420fbecaa46dbce00a Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 14 Nov 2023 12:35:05 +0530 Subject: [PATCH] feat: Track Semi-finished goods (including subcontracted items) against Job Cards --- .../doctype/purchase_order/purchase_order.py | 10 +- .../purchase_order_item.json | 17 +- erpnext/manufacturing/doctype/bom/bom.js | 54 ++++ erpnext/manufacturing/doctype/bom/bom.json | 44 ++- erpnext/manufacturing/doctype/bom/bom.py | 54 +++- .../doctype/bom_creator/bom_creator.js | 28 ++ .../doctype/bom_creator/bom_creator.json | 35 ++- .../doctype/bom_creator/bom_creator.py | 75 ++++- .../bom_creator_item/bom_creator_item.json | 37 ++- .../doctype/bom_item/bom_item.json | 9 +- .../doctype/bom_operation/bom_operation.json | 96 ++++++- .../doctype/job_card/job_card.js | 224 ++++++++++++--- .../doctype/job_card/job_card.json | 183 +++++++++--- .../doctype/job_card/job_card.py | 263 ++++++++++++------ .../doctype/job_card/job_card_dashboard.py | 1 + .../job_card_time_log/job_card_time_log.json | 6 +- .../doctype/work_order/work_order.js | 48 ++-- .../doctype/work_order/work_order.json | 54 ++-- .../doctype/work_order/work_order.py | 71 ++++- .../work_order_item/work_order_item.json | 9 +- .../work_order_operation.json | 69 ++++- erpnext/patches.txt | 1 + .../patches/v15_0/add_default_operations.py | 5 + .../bom_configurator.bundle.js | 49 ++-- erpnext/setup/install.py | 9 + .../doctype/stock_entry/stock_entry.json | 2 +- .../stock/doctype/stock_entry/stock_entry.py | 45 ++- .../stock_entry_type/stock_entry_type.py | 75 ++++- .../subcontracting_order_item.json | 21 +- .../subcontracting_receipt.py | 20 ++ .../subcontracting_receipt_item.json | 11 +- 31 files changed, 1322 insertions(+), 303 deletions(-) create mode 100644 erpnext/patches/v15_0/add_default_operations.py 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..254db63eb356 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:11: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..3dc8685e5cd1 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, + 'make_finished_good_against_job_card': 0 + } + }; + }); + frm.set_query("source_warehouse", "items", function () { return { filters: { @@ -85,6 +100,22 @@ 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); @@ -432,6 +463,29 @@ 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()) { diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index 67de6a0632b9..027d3ee50493 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", + "make_finished_good_against_job_card", "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", @@ -211,7 +214,7 @@ }, { "default": "Work Order", - "depends_on": "with_operations", + "depends_on": "eval: doc.with_operations === 1 && doc.make_finished_good_against_job_card === 0", "fieldname": "transfer_material_against", "fieldtype": "Select", "label": "Transfer Material Against", @@ -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" @@ -630,6 +630,30 @@ { "fieldname": "column_break_oxbz", "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "with_operations", + "description": "User can consume the raw materials, Add Semi Finished Goods / Final Finished Good against the Operation using Job Cards", + "fieldname": "make_finished_good_against_job_card", + "fieldtype": "Check", + "label": "Make Finished Good Against Job Card" + }, + { + "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 +661,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2024-04-02 16:22:47.518411", + "modified": "2024-04-02 16:23: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..69ea9fb112a7 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -245,6 +245,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 +545,9 @@ def clear_operations(self): if not self.with_operations: self.set("operations", []) + if not self.with_operations and self.make_finished_good_against_job_card: + self.make_finished_good_against_job_card = 0 + def clear_inspection(self): if not self.inspection_required: self.quality_inspection_template = None @@ -645,6 +649,33 @@ 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.make_finished_good_against_job_card: + 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_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 +1125,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, "make_finished_good_against_job_card"): + 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 +1158,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 +1168,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 +1184,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 +1196,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 [ @@ -1178,6 +1221,7 @@ def get_bom_items_as_dict( if not item_details.get(d[1]) or (company_in_record and company != company_in_record): item_dict[item][d[1]] = frappe.get_cached_value("Company", company, d[2]) if d[2] else None + print(item_dict) return item_dict diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.js b/erpnext/manufacturing/doctype/bom_creator/bom_creator.js index 32231aa49494..a83295529109 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.js +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.js @@ -88,6 +88,34 @@ 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("make_finished_good_against_job_card", 0); + } + } + }, + { + label: __("Make Finished Good Against Job Card"), + fieldtype: "Check", + fieldname: "make_finished_good_against_job_card", + depends_on: "eval:doc.track_operations" + }, + { fieldtype: "Column Break" }, + { + label: __("Final Operation"), + fieldtype: "Link", + fieldname: "final_operation", + options: "Operation", + default: "Assembly", + mandatory_depends_on: "eval:doc.make_finished_good_against_job_card", + depends_on: "eval:doc.make_finished_good_against_job_card" + }, ], primary_action_label: __("Create"), primary_action: (values) => { diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.json b/erpnext/manufacturing/doctype/bom_creator/bom_creator.json index 1e8237c03f78..1fccf0f3317c 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.json +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.json @@ -37,6 +37,11 @@ "items", "costing_detail", "raw_material_cost", + "configuration_section", + "track_operations", + "make_finished_good_against_job_card", + "column_break_obzr", + "final_operation", "remarks_tab", "remarks", "section_break_yixm", @@ -278,6 +283,34 @@ "fieldtype": "Text", "label": "Error Log", "read_only": 1 + }, + { + "fieldname": "configuration_section", + "fieldtype": "Section Break", + "label": "Operation" + }, + { + "default": "0", + "depends_on": "track_operations", + "fieldname": "make_finished_good_against_job_card", + "fieldtype": "Check", + "label": "Make Finished Good Against Job Card" + }, + { + "fieldname": "column_break_obzr", + "fieldtype": "Column Break" + }, + { + "fieldname": "final_operation", + "fieldtype": "Link", + "label": "Final Operation", + "options": "Operation" + }, + { + "default": "0", + "fieldname": "track_operations", + "fieldtype": "Check", + "label": "Track Operations" } ], "icon": "fa fa-sitemap", @@ -288,7 +321,7 @@ "link_fieldname": "bom_creator" } ], - "modified": "2024-04-02 16:30:59.779190", + "modified": "2024-04-02 16:31:59.779190", "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..08adc8ef9301 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py @@ -207,7 +207,8 @@ def validate_fields(self): frappe.throw(_("Please set {0} in BOM Creator {1}").format(label, self.name)) def on_submit(self): - self.enqueue_create_boms() + pass + # self.enqueue_create_boms() @frappe.whitelist() def enqueue_create_boms(self): @@ -260,6 +261,9 @@ def create_boms(self): 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.make_finished_good_against_job_card: + 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 +276,55 @@ 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, + "make_finished_good_against_job_card": 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, + "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, + }, + ) + + bom.append( + "operations", + { + "operation": self.final_operation, + "finished_good": self.item_code, + "finished_good_qty": self.qty, + "bom_no": production_item_wise_rm[(self.item_code, self.name)].bom_no, + }, + ) + + 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 +350,22 @@ def create_bom(self, row, production_item_wise_rm): } ) + if self.track_operations and not self.make_finished_good_against_job_card: + if row.item_code == self.item_code: + bom.with_operations = 1 + for item in self.items: + if not item.operation: + continue + + bom.append( + "operations", + { + "operation": item.operation, + "workstation_type": item.workstation_type, + "time_in_mins": item.operation_time, + }, + ) + for field in BOM_FIELDS: if self.get(field): bom.set(field, self.get(field)) @@ -402,6 +471,7 @@ def add_sub_assembly(**kwargs): name = kwargs.fg_reference_id parent_row_no = "" + print(kwargs) if not kwargs.convert_to_sub_assembly: item_info = get_item_details(bom_item.item_code) item_row = doc.append( @@ -417,6 +487,9 @@ 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, }, ) 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..fc555b309874 100644 --- a/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json +++ b/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json @@ -15,6 +15,11 @@ "is_expandable", "sourced_by_supplier", "bom_created", + "operation_section", + "operation", + "column_break_cbnk", + "workstation_type", + "operation_time", "description_section", "description", "quantity_and_rate_section", @@ -77,14 +82,15 @@ { "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 +231,39 @@ "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" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:06:40.764747", + "modified": "2024-03-27 13:06:41.764747", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Creator Item", diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.json b/erpnext/manufacturing/doctype/bom_item/bom_item.json index 226cfe0162fb..55ef70f24d23 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.make_finished_good_against_job_card ==1", + "fieldname": "operation_row_id", + "fieldtype": "Int", + "label": "Operation Row 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:07:41.079752", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Item", diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index aa62b027b06f..47f7c506a0fa 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json @@ -6,24 +6,33 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "sequence_id", "operation", + "sequence_id", + "finished_good", + "finished_good_qty", + "bom_no", "col_break1", "workstation_type", "workstation", "time_in_mins", "fixed_time", + "is_subcontracted", + "is_final_finished_good", + "set_cost_based_on_bom_qty", + "warehouse_section", + "source_warehouse", + "wip_warehouse", + "column_break_lbhy", + "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 +80,7 @@ "precision": "2" }, { + "columns": 1, "description": "In minutes", "fetch_from": "operation.total_operation_time", "fetch_if_empty": 1, @@ -87,7 +97,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 +181,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 +188,85 @@ "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" + }, + { + "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, + "fieldname": "source_warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Source WH", + "options": "Warehouse" + }, + { + "default": "0", + "fieldname": "is_subcontracted", + "fieldtype": "Check", + "label": "Is Subcontracted" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:06:41.248462", + "modified": "2024-03-27 13:07:41.248462", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Operation", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 4cc60a3b4a6d..61aec73506a3 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -32,22 +32,39 @@ 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")); + } + }, + refresh: function (frm) { frappe.flags.pause_job = 0; frappe.flags.resume_job = 0; 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) { - let to_request = frm.doc.for_quantity > frm.doc.transferred_qty; + if (!frm.is_new() && !frm.doc.skip_material_transfer && has_items && frm.doc.docstatus === 1) { + let to_request = frm.doc.for_quantity > frm.doc.material_transferred_for_manufacturing; let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer; if (to_request || excess_transfer_allowed) { @@ -67,7 +84,7 @@ frappe.ui.form.on("Job Card", { } } - 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"); } @@ -83,12 +100,10 @@ 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) - ) { + if (!frm.doc.finished_good && frm.doc.docstatus == 1 && !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)) { + // 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) { @@ -110,13 +125,45 @@ frappe.ui.form.on("Job Card", { } else { frm.trigger("prepare_timer_buttons"); } + } else if (frm.doc.finished_good && frm.doc.docstatus == 1 + && (frm.doc.for_quantity - frm.doc.material_transferred_for_manufacturing <= 0 || frm.doc.skip_material_transfer)) { + if (!frm.doc.time_logs?.length) { + frm.add_custom_button(__("Start Job"), () => { + 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", + } + ], data => { + frm.events.start_timer(frm, data.start_time, data.employee); + }, __("Enter Value"), __("Start Job")); + }).addClass("btn-primary"); + } else { + if (frm.doc.for_quantity - frm.doc.manufactured_qty > 0) { + frm.add_custom_button(__("Complete Job"), () => { + frm.trigger("make_finished_good"); + }).addClass("btn-primary"); + } + + frm.trigger("make_dashboard"); + } } frm.trigger("setup_quality_inspection"); 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") { + frappe.db.get_value("Work Order", frm.doc.work_order, + "transfer_material_against").then((r) => { + if (r.message.transfer_material_against == "Work Order" && !frm.doc.operation_row_id) { frm.set_df_property("items", "hidden", 1); } }); @@ -134,7 +181,70 @@ frappe.ui.form.on("Job Card", { } }, - setup_quality_inspection: function (frm) { + 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, employee) { + frm.call({ + method: "start_timer", + doc: frm.doc, + args: { + start_time: start_time, + employee: employee + }, + 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"), __("Make Stock Entry"), __("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) { return { @@ -262,7 +372,11 @@ frappe.ui.form.on("Job Card", { frm.toggle_reqd("operation_row_number", !frm.doc.operation_id && frm.doc.operation); }, - prepare_timer_buttons: function (frm) { + prepare_timer_buttons(frm) { + if (in_list(["Completed", "Materials Not Consumed"], frm.doc.status)) { + return; + } + frm.trigger("make_dashboard"); if (!frm.doc.started_time && !frm.doc.current_time) { @@ -286,7 +400,7 @@ frappe.ui.form.on("Job Card", { }).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); + frm.events.start_job(frm, "Work In Progress", frm.doc.employee); }).addClass("btn-primary"); } else { frm.add_custom_button(__("Pause Job"), () => { @@ -307,20 +421,27 @@ frappe.ui.form.on("Job Card", { } 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") - ); + let fields = [{ + fieldtype: "Float", + label: __("Completed Quantity"), + fieldname: "qty", + default: frm.doc.for_quantity + }]; + + fields.push({ + fieldtype: "Datetime", + label: __("End Time"), + fieldname: "to_time", + default: frappe.datetime.now_datetime() + }); + + let label = frm.doc.finished_good ? __("Make Stock Entry") : __("Complete Job"); + + frappe.prompt(fields, data => { + frm.events.complete_job(frm, "Completed", data.qty, data.to_time); + }, __("Enter Value"), label, __("Set Quantity")); } else { - frm.events.complete_job(frm, "Complete", 0.0); + frm.events.complete_job(frm, "Completed", 0.0); } }).addClass("btn-primary"); } @@ -336,12 +457,12 @@ frappe.ui.form.on("Job Card", { frm.events.make_time_log(frm, args); }, - complete_job: function (frm, status, completed_qty) { + complete_job: function(frm, status, completed_qty, to_time) { const args = { job_card_id: frm.doc.name, - complete_time: frappe.datetime.now_datetime(), status: status, completed_qty: completed_qty, + complete_time: to_time || frappe.datetime.now_datetime(), }; frm.events.make_time_log(frm, args); }, @@ -415,7 +536,7 @@ frappe.ui.form.on("Job Card", { frm.dashboard.refresh(); const timer = `
00 : 00 @@ -424,19 +545,34 @@ 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.finished_good && frm.doc.time_logs?.length + && frm.doc.time_logs[0].to_time + ) { + updateStopwatch(currentIncrement); + } else if (frm.doc.status == "On Hold") { + updateStopwatch(currentIncrement); + } else { + initialiseTimer(); + } + }, + + get_current_time(frm) { + let current_time = 0; - let currentIncrement = frm.doc.current_time || 0; - if (frm.doc.started_time || frm.doc.current_time) { - if (frm.doc.status == "On Hold") { - updateStopwatch(currentIncrement); + 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 += frappe.datetime.get_minute_diff(d.to_time, d.from_time) * 60; + } } else { - currentIncrement += moment(frappe.datetime.now_datetime()).diff( - moment(frm.doc.started_time), - "seconds" - ); - initialiseTimer(); + current_time += frappe.datetime.get_minute_diff(frappe.datetime.now_datetime(), d.from_time) * 60; } - } + }) + + return current_time; }, hide_timer: function (frm) { @@ -492,6 +628,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", { diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 531c71f9c634..2faaf8c21433 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", + "material_transferred_for_manufacturing", + "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", "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 }, @@ -136,16 +150,17 @@ "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,7 +172,9 @@ "hide_border": 1 }, { + "allow_on_submit": 1, "default": "0", + "depends_on": "eval:doc.is_subcontracted===0", "fieldname": "total_completed_qty", "fieldtype": "Float", "label": "Total Completed Qty", @@ -175,7 +192,7 @@ }, { "fieldname": "section_break_8", - "fieldtype": "Tab Break", + "fieldtype": "Section Break", "label": "Raw Materials" }, { @@ -227,6 +244,7 @@ "fieldtype": "Column Break" }, { + "allow_on_submit": 1, "default": "Open", "fieldname": "status", "fieldtype": "Select", @@ -236,16 +254,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 +282,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": "Finished Good", "options": "Item", "read_only": 1 }, @@ -302,6 +312,7 @@ "label": "Item Name" }, { + "allow_on_submit": 1, "fieldname": "current_time", "fieldtype": "Int", "hidden": 1, @@ -384,6 +395,7 @@ "options": "Operation" }, { + "allow_on_submit": 1, "fieldname": "employee", "fieldtype": "Table MultiSelect", "label": "Employee", @@ -463,6 +475,7 @@ "show_dashboard": 1 }, { + "depends_on": "expected_start_date", "fieldname": "scheduled_time_section", "fieldtype": "Section Break", "label": "Scheduled Time" @@ -476,10 +489,6 @@ "fieldtype": "Float", "label": "Expected Time Required (In Mins)" }, - { - "fieldname": "section_break_05am", - "fieldtype": "Section Break" - }, { "fieldname": "scheduled_time_logs", "fieldtype": "Table", @@ -507,11 +516,101 @@ { "fieldname": "column_break_fcmp", "fieldtype": "Column Break" + }, + { + "fieldname": "finished_good", + "fieldtype": "Link", + "label": "Semi 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" + }, + { + "depends_on": "eval:doc.is_subcontracted===0 && doc.finished_good", + "fieldname": "material_transferred_for_manufacturing", + "fieldtype": "Float", + "label": "Material Transferred for Manufacturing", + "no_copy": 1, + "read_only": 1 + }, + { + "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" } ], "is_submittable": 1, "links": [], - "modified": "2024-03-27 13:09:56.634418", + "modified": "2024-03-27 13:10:56.634418", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index c565c910c4e2..012743454f5b 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, @@ -20,14 +20,15 @@ get_time, 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): @@ -140,7 +141,6 @@ def before_validate(self): self.set_wip_warehouse() def validate(self): - self.validate_time_logs() self.set_status() self.validate_operation_id() self.validate_sequence_id() @@ -151,6 +151,27 @@ def validate(self): def on_update(self): self.validate_job_card_qty() + 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 +532,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,36 +560,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") - def update_sub_operation_status(self): if not (self.sub_operations and self.time_logs): return @@ -628,23 +631,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.make_finished_good_against_job_card 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 +674,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 +682,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 +753,9 @@ def set_process_loss(self): ) def update_work_order(self): + if self.finished_good: + return + if not self.work_order: return @@ -773,6 +784,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,55 +938,28 @@ 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("material_transferred_for_manufacturing", qty) self.set_status(update_status) def set_status(self, update_status=False): @@ -969,9 +967,14 @@ def set_status(self, update_status=False): return 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.material_transferred_for_manufacturing > 0: + self.status = "Work In Progress" - if self.docstatus < 2: - if flt(self.for_quantity) <= flt(self.transferred_qty): + if not self.finished_good and self.docstatus < 2: + if flt(self.for_quantity) <= flt(self.material_transferred_for_manufacturing): self.status = "Material Transferred" if self.time_logs: @@ -1075,6 +1078,104 @@ 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) + if kwargs.start_time and not kwargs.end_time: + row = self.append( + "time_logs", + { + "from_time": kwargs.start_time, + "employee": kwargs.employee, + }, + ) + else: + row = self.time_logs[-1] + row.to_time = kwargs.end_time + row.time_in_mins = time_diff_in_minutes(kwargs.end_time, row.from_time) + row.completed_qty = kwargs.completed_qty + + if row: + row.db_update() + + @frappe.whitelist() + def start_timer(self, start_time=None, employee=None): + if start_time: + self.add_time_logs(start_time=start_time, employee=employee) + + @frappe.whitelist() + def make_finished_good(self, qty, end_time=None): + from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry + + if end_time: + self.add_time_logs(end_time=end_time, completed_qty=qty) + + ste = ManufactureEntry( + { + "qty_to_manufacture": 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() + 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) + print(_item_details) + + 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): @@ -1085,6 +1186,7 @@ def make_time_log(args): doc = frappe.get_doc("Job Card", args.job_card_id) doc.validate_sequence_id() doc.add_time_log(args) + doc.set_status(update_status=True) @frappe.whitelist() @@ -1271,7 +1373,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/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 1da33f0ad9b1..72442aafae35 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -143,19 +143,10 @@ frappe.ui.form.on("Work Order", { } if (frm.doc.status != "Closed") { - if ( - frm.doc.docstatus === 1 && - frm.doc.status !== "Completed" && - frm.doc.operations && - frm.doc.operations.length - ) { - const not_completed = frm.doc.operations.filter((d) => { - if (d.status != "Completed") { - return true; - } - }); + if (frm.doc.docstatus === 1 && frm.doc.status !== "Completed" + && frm.doc.operations && frm.doc.operations.length) { - 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"); @@ -615,22 +606,25 @@ erpnext.work_order = { ); } - const show_start_btn = - frm.doc.skip_transfer || frm.doc.transfer_material_against == "Job Card" ? 0 : 1; + if (!frm.doc.make_finished_good_against_job_card) { + 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..a08bd0de2696 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", + "make_finished_good_against_job_card", + "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.make_finished_good_against_job_card === 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.make_finished_good_against_job_card", "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.make_finished_good_against_job_card === 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.make_finished_good_against_job_card", + "fieldname": "make_finished_good_against_job_card", + "fieldtype": "Check", + "label": "Make Finished Good Against Job Card", + "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:12: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..243a1a0cb135 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.make_finished_good_against_job_card: + 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")) + 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.make_finished_good_against_job_card: + return + from erpnext.manufacturing.doctype.production_plan.production_plan import ( get_reserved_qty_for_sub_assembly, ) @@ -811,10 +837,16 @@ 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", @@ -1084,6 +1116,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 +1317,9 @@ 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.make_finished_good_against_job_card = frappe.db.get_value( + "BOM", bom_no, "make_finished_good_against_job_card" + ) wo_doc.production_item = item wo_doc.update(item_details) wo_doc.bom_no = bom_no @@ -1450,6 +1486,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 +1496,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 +1611,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 +1619,20 @@ 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"), + "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.make_finished_good_against_job_card 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..45e3d77f749d 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,14 @@ "workstation_type", "workstation", "sequence_id", + "section_break_insy", + "bom_no", + "finished_good", + "is_subcontracted", + "column_break_vjih", + "source_warehouse", + "wip_warehouse", + "fg_warehouse", "section_break_10", "description", "estimated_time_and_cost", @@ -52,7 +60,6 @@ "columns": 2, "fieldname": "bom", "fieldtype": "Link", - "in_list_view": 1, "label": "BOM", "no_copy": 1, "options": "BOM", @@ -66,11 +73,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 +219,69 @@ "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.make_finished_good_against_job_card === 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 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:11:00.595376", + "modified": "2024-03-27 13:12:00.595376", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Operation", diff --git a/erpnext/patches.txt b/erpnext/patches.txt index ac6f8eb5ebee..4f02329f0a31 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -368,3 +368,4 @@ erpnext.patches.v15_0.fix_debit_credit_in_transaction_currency erpnext.patches.v15_0.rename_purchase_receipt_amount_to_purchase_amount erpnext.patches.v14_0.enable_set_priority_for_pricing_rules #1 erpnext.patches.v15_0.rename_number_of_depreciations_booked_to_opening_booked_depreciations +erpnext.patches.v15_0.add_default_operations diff --git a/erpnext/patches/v15_0/add_default_operations.py b/erpnext/patches/v15_0/add_default_operations.py new file mode 100644 index 000000000000..5160a3d42602 --- /dev/null +++ b/erpnext/patches/v15_0/add_default_operations.py @@ -0,0 +1,5 @@ +from erpnext.setup.install import make_default_operations + + +def execute(): + make_default_operations() diff --git a/erpnext/public/js/bom_configurator/bom_configurator.bundle.js b/erpnext/public/js/bom_configurator/bom_configurator.bundle.js index 5061be9d20a2..777111254b2c 100644 --- a/erpnext/public/js/bom_configurator/bom_configurator.bundle.js +++ b/erpnext/public/js/bom_configurator/bom_configurator.bundle.js @@ -234,7 +234,7 @@ class BOMConfigurator { add_sub_assembly(node, view) { let dialog = new frappe.ui.Dialog({ - fields: view.events.get_sub_assembly_modal_fields(), + fields: view.events.get_sub_assembly_modal_fields(node.is_root), title: __("Add Sub Assembly"), }); @@ -255,6 +255,9 @@ class BOMConfigurator { fg_item: node.data.value, fg_reference_id: node.data.name || this.frm.doc.name, bom_item: bom_item, + operation: node.data.operation, + workstation_type: node.data.workstation_type, + operation_time: node.data.operation_time, }, callback: (r) => { view.events.load_tree(r, node); @@ -265,25 +268,24 @@ class BOMConfigurator { }); } - get_sub_assembly_modal_fields(read_only = false) { - return [ - { - label: __("Sub Assembly Item"), - fieldname: "item_code", - fieldtype: "Link", - options: "Item", - reqd: 1, - read_only: read_only, - }, + get_sub_assembly_modal_fields(is_root=false, read_only=false) { + let fields = [ + { label: __("Sub Assembly Item"), fieldname: "item_code", fieldtype: "Link", options: "Item", reqd: 1, read_only: read_only }, { fieldtype: "Column Break" }, - { - label: __("Qty"), - fieldname: "qty", - default: 1.0, - fieldtype: "Float", - reqd: 1, - read_only: read_only, - }, + { label: __("Qty"), fieldname: "qty", default: 1.0, fieldtype: "Float", reqd: 1, read_only: read_only }, + ] + + if (this.frm.doc.make_finished_good_against_job_card && is_root) { + fields.push(...[ + { fieldtype: "Section Break" }, + { label: __("Operation"), fieldname: "operation", fieldtype: "Link", options: "Operation", read_only: read_only, reqd: 1, }, + { fieldtype: "Column Break" }, + { label: __("Workstation Type"), fieldname: "workstation_type", fieldtype: "Link", options: "Workstation Type", read_only: read_only, reqd: 1, }, + { label: __("Operation Time"), fieldname: "operation_time", fieldtype: "Int", read_only: read_only, reqd: 1, }, + ]) + } + + fields.push(...[ { fieldtype: "Section Break" }, { label: __("Raw Materials"), @@ -309,12 +311,14 @@ class BOMConfigurator { }, ], }, - ]; + ]) + + return fields; } convert_to_sub_assembly(node, view) { let dialog = new frappe.ui.Dialog({ - fields: view.events.get_sub_assembly_modal_fields(true), + fields: view.events.get_sub_assembly_modal_fields(node.is_root, true), title: __("Add Sub Assembly"), }); @@ -337,6 +341,9 @@ class BOMConfigurator { bom_item: bom_item, fg_reference_id: node.data.name || this.frm.doc.name, convert_to_sub_assembly: true, + operation: node.data.operation, + workstation_type: node.data.workstation_type, + operation_time: node.data.operation_time, }, callback: (r) => { node.expandable = true; diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 9ad6b9936330..88adbfb8c2e1 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -32,6 +32,7 @@ def after_install(): add_standard_navbar_items() add_app_name() update_roles() + make_default_operations() frappe.db.commit() @@ -42,6 +43,14 @@ def check_setup_wizard_not_completed(): frappe.throw(message) # nosemgrep +def make_default_operations(): + for operation in ["Assembly"]: + if not frappe.db.exists("Operation", operation): + doc = frappe.get_doc({"doctype": "Operation", "name": operation}) + doc.flags.ignore_mandatory = True + doc.insert(ignore_permissions=True) + + def set_single_defaults(): for dt in ( "Accounts Settings", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 7f671d2f8fbd..a153dba06d95 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -14,6 +14,7 @@ "purpose", "add_to_transit", "work_order", + "job_card", "purchase_order", "subcontracting_order", "delivery_note_no", @@ -79,7 +80,6 @@ "col5", "per_transferred", "total_amount", - "job_card", "amended_from", "credit_note", "is_return" diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 61be28493262..a83a357c7c22 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -211,7 +211,10 @@ def validate(self): if self.purpose in ("Manufacture", "Repack"): self.mark_finished_and_scrap_items() - self.validate_finished_goods() + if not self.job_card: + self.validate_finished_goods() + else: + self.validate_job_card_fg_item() self.validate_with_material_request() self.validate_batch() @@ -303,10 +306,22 @@ def set_job_card_data(self): self.from_bom = 1 self.bom_no = data.bom_no - def validate_job_card_item(self): + def validate_job_card_fg_item(self): if not self.job_card: return + job_card = frappe.db.get_value( + "Job Card", self.job_card, ["finished_good", "manufactured_qty"], as_dict=1 + ) + + for row in self.items: + if row.is_finished_item and row.item_code != job_card.finished_good: + frappe.throw(_("Row #{0}: Finished Good must be {1}").format(row.idx, job_card.fininshed_good)) + + def validate_job_card_item(self): + if not self.job_card or self.purpose == "Manufacture": + return + if cint(frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer")): return @@ -341,13 +356,6 @@ def validate_purpose(self): if self.purpose not in valid_purposes: frappe.throw(_("Purpose must be one of {0}").format(comma_or(valid_purposes))) - if self.job_card and self.purpose not in ["Material Transfer for Manufacture", "Repack"]: - frappe.throw( - _( - "For job card {0}, you can only make the 'Material Transfer for Manufacture' type stock entry" - ).format(self.job_card) - ) - def delete_linked_stock_entry(self): if self.purpose == "Send to Warehouse": for d in frappe.get_all( @@ -624,8 +632,13 @@ def validate_work_order(self): # check if work order is entered if ( - self.purpose == "Manufacture" or self.purpose == "Material Consumption for Manufacture" - ) and self.work_order: + (self.purpose == "Manufacture" or self.purpose == "Material Consumption for Manufacture") + and self.work_order + and frappe.get_cached_value( + "Work Order", self.work_order, "make_finished_good_against_job_card" + ) + != 1 + ): if not self.fg_completed_qty: frappe.throw(_("For Quantity (Manufactured Qty) is mandatory")) self.check_if_operations_completed() @@ -1575,8 +1588,14 @@ def _validate_work_order(pro_doc): if self.job_card: job_doc = frappe.get_doc("Job Card", self.job_card) - job_doc.set_transferred_qty(update_status=True) - job_doc.set_transferred_qty_in_job_card_item(self) + if self.purpose != "Manufacture": + job_doc.set_transferred_qty(update_status=True) + job_doc.set_transferred_qty_in_job_card_item(self) + else: + job_doc.set_manufactured_qty() + + if self.job_card and frappe.get_cached_value("Job Card", self.job_card, "finished_good"): + return if self.work_order: pro_doc = frappe.get_doc("Work Order", self.work_order) diff --git a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py index 034223122f60..fb6672eb4a4e 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py @@ -2,9 +2,11 @@ # For license information, please see license.txt -# import frappe +import frappe from frappe.model.document import Document +from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict + class StockEntryType(Document): # begin: auto-generated types @@ -32,3 +34,74 @@ class StockEntryType(Document): def validate(self): if self.add_to_transit and self.purpose != "Material Transfer": self.add_to_transit = 0 + + +class ManufactureEntry: + def __init__(self, kwargs) -> None: + for key, value in kwargs.items(): + setattr(self, key, value) + + def make_stock_entry(self): + self.stock_entry = frappe.new_doc("Stock Entry") + self.stock_entry.purpose = self.purpose + self.stock_entry.company = self.company + self.stock_entry.from_bom = 1 + self.stock_entry.bom_no = self.bom_no + self.stock_entry.use_multi_level_bom = 1 + self.stock_entry.fg_completed_qty = self.qty_to_manufacture + self.stock_entry.project = self.project + self.stock_entry.job_card = self.job_card + self.stock_entry.work_order = self.work_order + self.stock_entry.set_stock_entry_type() + + self.prepare_source_warehouse() + self.add_raw_materials() + self.add_finished_good() + + def prepare_source_warehouse(self): + self.source_wh = {} + if self.skip_material_transfer: + if not self.backflush_from_wip_warehouse: + self.source_wh = frappe._dict( + frappe.get_all( + "Job Card Item", + filters={"parent": self.job_card}, + fields=["item_code", "source_warehouse"], + as_list=1, + ) + ) + + def add_raw_materials(self): + if self.job_card: + item_dict = get_bom_items_as_dict( + self.bom_no, + self.company, + qty=self.qty_to_manufacture, + fetch_exploded=False, + fetch_qty_in_stock_uom=False, + ) + + for item_code, _dict in item_dict.items(): + _dict.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse + _dict.to_warehouse = "" + + self.stock_entry.add_to_stock_entry_detail(item_dict) + + def add_finished_good(self): + from erpnext.stock.doctype.item.item import get_item_defaults + + item = get_item_defaults(self.production_item, self.company) + + args = { + "to_warehouse": self.fg_warehouse, + "from_warehouse": "", + "qty": self.qty_to_manufacture, + "item_name": item.item_name, + "description": item.description, + "stock_uom": item.stock_uom, + "expense_account": item.get("expense_account"), + "cost_center": item.get("buying_cost_center"), + "is_finished_item": 1, + } + + self.stock_entry.add_to_stock_entry_detail({self.production_item: args}, bom_no=self.bom_no) diff --git a/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json b/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json index c0ab5146077d..855a8cecf3fd 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json @@ -49,6 +49,9 @@ "cost_center", "dimension_col_break", "project", + "references_section", + "job_card", + "column_break_nfod", "section_break_34", "purchase_order_item", "page_break" @@ -378,13 +381,29 @@ "no_copy": 1, "read_only": 1, "search_index": 1 + }, + { + "fieldname": "references_section", + "fieldtype": "Section Break", + "label": "References" + }, + { + "fieldname": "job_card", + "fieldtype": "Link", + "label": "Job Card", + "options": "Job Card", + "read_only": 1 + }, + { + "fieldname": "column_break_nfod", + "fieldtype": "Column Break" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:46.343298", + "modified": "2024-03-27 13:12:46.343298", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Order Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index cb0eca1b75ea..7d6e15a8ee92 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -3,6 +3,7 @@ import frappe from frappe import _ +from frappe.query_builder.functions import Sum from frappe.model.mapper import get_mapped_doc from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate @@ -155,6 +156,7 @@ def on_submit(self): self.repost_future_sle_and_gle() self.update_status() self.auto_create_purchase_receipt() + self.update_job_card() def on_update(self): for table_field in ["items", "supplied_items"]: @@ -178,6 +180,7 @@ def on_cancel(self): self.repost_future_sle_and_gle() self.update_status() self.delete_auto_created_batches() + self.update_job_card() @frappe.whitelist() def reset_raw_materials(self): @@ -189,6 +192,23 @@ def validate_closed_subcontracting_order(self): if item.subcontracting_order: check_on_hold_or_closed_status("Subcontracting Order", item.subcontracting_order) + def update_job_card(self): + for row in self.get("items"): + if row.job_card: + doc = frappe.get_doc("Job Card", row.job_card) + doc.set_manufactured_qty() + + def get_manufactured_qty(self, job_card): + table = frappe.qb.DocType("Subcontracting Receipt Item") + query = ( + frappe.qb.from_(table) + .select(Sum(table.qty)) + .where((table.job_card == job_card) & (table.docstatus == 1)) + ) + + qty = query.run()[0][0] or 0.0 + return flt(qty) + def validate_items_qty(self): for item in self.items: if not (item.qty or item.rejected_qty): diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json index a0be6c3f066e..3c8474d20295 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json @@ -39,6 +39,7 @@ "subcontracting_order", "subcontracting_order_item", "subcontracting_receipt_item", + "job_card", "column_break_40", "rejected_warehouse", "bom", @@ -577,12 +578,20 @@ "fieldname": "add_serial_batch_for_rejected_qty", "fieldtype": "Button", "label": "Add Serial / Batch No (Rejected Qty)" + }, + { + "fieldname": "job_card", + "fieldtype": "Link", + "label": "Job Card", + "options": "Job Card", + "read_only": 1, + "search_index": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2024-03-29 15:42:43.425544", + "modified": "2024-03-29 15:43:43.425544", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Receipt Item",