From 1f7f8cd9d3e7d38b51d98beadf41452e437f3f4f Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Fri, 28 Aug 2026 12:56:06 +0530 Subject: [PATCH] feat: alternative finished goods conversion against work order (#58479) * feat: alternative finished goods conversion against work order * fix: tighten validations for finished goods conversion * fix: postgres compatible lock and qty checks post transfer qty for fg conversion * fix: default single alternative item and hide Change Finished Item button without alternatives --- .../manufacturing_settings.json | 10 +- .../manufacturing_settings.py | 1 + .../doctype/work_order/mapper.py | 71 ++++++++++ .../doctype/work_order/test_work_order.py | 94 +++++++++++++ .../doctype/work_order/work_order.js | 96 ++++++++++++++ .../doctype/work_order/work_order.py | 11 ++ .../work_order_summary/work_order_summary.py | 69 +++++++++- .../stock_entry/services/manufacturing.py | 124 ++++++++++++++++++ .../doctype/stock_entry/stock_entry.json | 15 ++- .../stock/doctype/stock_entry/stock_entry.py | 29 ++-- 10 files changed, 507 insertions(+), 13 deletions(-) diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json index 773ec8367a1..757212acba2 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json @@ -16,6 +16,7 @@ "update_bom_costs_automatically", "column_break_lhyt", "allow_editing_of_items_and_quantities_in_work_order", + "allow_alternative_finished_goods", "over_production_for_sales_and_work_order_section", "overproduction_percentage_for_sales_order", "column_break_16", @@ -231,6 +232,13 @@ "fieldtype": "Check", "label": "Allow Editing of Items and Quantities in Work Order" }, + { + "default": "0", + "description": "If enabled, the produced item of a Work Order can be converted into one of its alternative items (defined via Item Alternative) using the 'Change Finished Item' action. The conversion creates a Repack entry linked to the Work Order.", + "fieldname": "allow_alternative_finished_goods", + "fieldtype": "Check", + "label": "Allow Alternative Finished Goods" + }, { "default": "0", "description": "To include sub-assembly costs and secondary items in Finished Goods on a work order without using a job card, when the 'Use Multi-Level BOM' option is enabled.", @@ -244,7 +252,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-03-16 13:28:20.714576", + "modified": "2026-08-27 10:00:00.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "Manufacturing Settings", diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py index 2913d70395d..1074ccb592e 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py @@ -18,6 +18,7 @@ class ManufacturingSettings(Document): from frappe.types import DF add_corrective_operation_cost_in_finished_good_valuation: DF.Check + allow_alternative_finished_goods: DF.Check allow_editing_of_items_and_quantities_in_work_order: DF.Check allow_overtime: DF.Check allow_production_on_holidays: DF.Check diff --git a/erpnext/manufacturing/doctype/work_order/mapper.py b/erpnext/manufacturing/doctype/work_order/mapper.py index 90ab3c8189e..016d3eed99c 100644 --- a/erpnext/manufacturing/doctype/work_order/mapper.py +++ b/erpnext/manufacturing/doctype/work_order/mapper.py @@ -289,6 +289,77 @@ def _set_stock_entry_warehouses(stock_entry, work_order, purpose, target_warehou stock_entry.source_stock_entry = source_stock_entry +@frappe.whitelist() +def get_fg_conversion_details(work_order: str): + from erpnext.stock.doctype.stock_entry.services.manufacturing import ( + get_alternative_finished_goods, + get_converted_fg_qty, + ) + + if not work_order or not isinstance(work_order, str): + frappe.throw(_("Invalid Work Order")) + + frappe.has_permission("Work Order", "read", doc=work_order, throw=True) + + wo_details = frappe.db.get_value( + "Work Order", work_order, ["production_item", "produced_qty"], as_dict=True + ) + + return { + "alternative_items": get_alternative_finished_goods(wo_details.production_item), + "available_qty": flt(wo_details.produced_qty) - get_converted_fg_qty(work_order), + } + + +@frappe.whitelist() +def make_fg_conversion_entry(work_order: str, item_code: str, qty: float): + if not (work_order and isinstance(work_order, str) and item_code and isinstance(item_code, str)): + frappe.throw(_("Invalid Work Order or Item")) + + qty = flt(qty) + if qty <= 0: + frappe.throw(_("The qty to convert must be greater than zero.")) + + frappe.has_permission("Stock Entry", "create", throw=True) + frappe.has_permission("Work Order", "read", doc=work_order, throw=True) + + wo_doc = frappe.get_doc("Work Order", work_order) + + stock_entry = frappe.new_doc("Stock Entry") + stock_entry.purpose = "Repack" + stock_entry.is_fg_conversion = 1 + stock_entry.work_order = wo_doc.name + stock_entry.company = wo_doc.company + stock_entry.set_stock_entry_type() + + stock_entry.append("items", _get_fg_conversion_row(wo_doc.production_item, qty, wo_doc.fg_warehouse)) + + target_row = _get_fg_conversion_row(item_code, qty, wo_doc.fg_warehouse, is_target=True) + stock_entry.append("items", target_row) + + return stock_entry.as_dict() + + +def _get_fg_conversion_row(item_code, qty, warehouse, is_target=False): + stock_uom = frappe.get_cached_value("Item", item_code, "stock_uom") + row = { + "item_code": item_code, + "qty": flt(qty), + "transfer_qty": flt(qty), + "uom": stock_uom, + "stock_uom": stock_uom, + "conversion_factor": 1, + "use_serial_batch_fields": 1, + } + + if is_target: + row.update({"t_warehouse": warehouse, "is_finished_item": 1}) + else: + row["s_warehouse"] = warehouse + + return row + + @frappe.whitelist() def make_job_card(work_order: str, operations: str | list, parent_bom: str | None = None): frappe.has_permission("Job Card", "create", throw=True) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 1121c9fedbf..b62d1d4943a 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -5684,6 +5684,100 @@ class TestWorkOrder(ERPNextTestSuite): wo.track_semi_finished_goods = 0 self.assertRaises(frappe.ValidationError, wo.validate_warehouse) + @ERPNextTestSuite.change_settings("Manufacturing Settings", {"allow_alternative_finished_goods": 1}) + def test_change_finished_item_to_alternative_finished_good(self): + from erpnext.manufacturing.doctype.work_order.mapper import ( + get_fg_conversion_details, + make_fg_conversion_entry, + ) + + wo_order, alt_item, other_item = prepare_data_for_fg_conversion_test() + + details = get_fg_conversion_details(wo_order.name) + self.assertEqual(details["alternative_items"], [alt_item]) + self.assertEqual(details["available_qty"], 10.0) + + conversion_entry = frappe.get_doc(make_fg_conversion_entry(wo_order.name, alt_item, 4)) + self.assertEqual(conversion_entry.purpose, "Repack") + self.assertEqual(conversion_entry.work_order, wo_order.name) + self.assertTrue(conversion_entry.is_fg_conversion) + conversion_entry.insert() + conversion_entry.submit() + + fg_row = next(row for row in conversion_entry.items if row.is_finished_item) + self.assertEqual(fg_row.item_code, alt_item) + self.assertEqual(flt(fg_row.amount), 400.0) + + self.assertEqual(frappe.db.get_value("Work Order", wo_order.name, "produced_qty"), 10) + self.assertEqual(get_fg_conversion_details(wo_order.name)["available_qty"], 6.0) + + excess_entry = frappe.get_doc(make_fg_conversion_entry(wo_order.name, alt_item, 7)) + self.assertRaises(frappe.ValidationError, excess_entry.insert) + + invalid_item_entry = frappe.get_doc(make_fg_conversion_entry(wo_order.name, other_item, 2)) + self.assertRaises(frappe.ValidationError, invalid_item_entry.insert) + + mismatch_entry = frappe.get_doc(make_fg_conversion_entry(wo_order.name, alt_item, 2)) + for row in mismatch_entry.items: + if row.is_finished_item: + row.qty = 3 + self.assertRaises(frappe.ValidationError, mismatch_entry.insert) + + self.assertRaises(frappe.ValidationError, make_fg_conversion_entry, wo_order.name, alt_item, 0) + + @ERPNextTestSuite.change_settings("Manufacturing Settings", {"allow_alternative_finished_goods": 0}) + def test_fg_conversion_not_allowed_when_setting_is_disabled(self): + from erpnext.manufacturing.doctype.work_order.mapper import make_fg_conversion_entry + + wo_order, alt_item, _ = prepare_data_for_fg_conversion_test() + + conversion_entry = frappe.get_doc(make_fg_conversion_entry(wo_order.name, alt_item, 2)) + self.assertRaises(frappe.ValidationError, conversion_entry.insert) + + +def prepare_data_for_fg_conversion_test(): + fg_item = make_item("_Test FG Conversion Item", {"is_stock_item": 1, "allow_alternative_item": 1}).name + alt_item = make_item("_Test FG Conversion Alt Item", {"is_stock_item": 1}).name + other_item = make_item("_Test FG Conversion Other Item", {"is_stock_item": 1}).name + rm_item = make_item("_Test FG Conversion RM", {"is_stock_item": 1, "valuation_rate": 100}).name + + frappe.db.set_value("Item", fg_item, "allow_alternative_item", 1) + if not frappe.db.exists("Item Alternative", {"item_code": fg_item, "alternative_item_code": alt_item}): + frappe.get_doc( + {"doctype": "Item Alternative", "item_code": fg_item, "alternative_item_code": alt_item} + ).insert() + + bom = frappe.get_doc( + { + "doctype": "BOM", + "item": fg_item, + "currency": "INR", + "quantity": 1, + "company": "_Test Company", + } + ) + bom.append("items", {"item_code": rm_item, "qty": 1}) + bom.insert() + bom.submit() + + wo_order = make_wo_order_test_record( + production_item=fg_item, + bom_no=bom.name, + qty=10, + skip_transfer=1, + source_warehouse="_Test Warehouse - _TC", + ) + + test_stock_entry.make_stock_entry( + item_code=rm_item, target="_Test Warehouse - _TC", qty=10, basic_rate=100 + ) + + manufacture_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) + manufacture_entry.insert() + manufacture_entry.submit() + + return wo_order, alt_item, other_item + def get_reserved_entries(voucher_no, warehouse=None): doctype = frappe.qb.DocType("Stock Reservation Entry") diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 639bf2a2fcb..915b041675b 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -245,6 +245,7 @@ frappe.ui.form.on("Work Order", { } frm.trigger("add_custom_button_to_return_components"); + frm.trigger("add_change_finished_item_button"); frm.trigger("allow_alternative_item"); frm.trigger("hide_reserve_stock_button"); frm.trigger("toggle_items_editable"); @@ -313,6 +314,101 @@ frappe.ui.form.on("Work Order", { } }, + add_change_finished_item_button: function (frm) { + if ( + frm.doc.docstatus !== 1 || + ["Stopped", "Closed"].includes(frm.doc.status) || + !frm.doc.__onload?.allow_alternative_finished_goods || + !frm.doc.__onload?.has_alternative_finished_goods || + !flt(frm.doc.produced_qty) + ) { + return; + } + + frm.add_custom_button(__("Change Finished Item"), () => { + frm.trigger("change_finished_item"); + }); + }, + + change_finished_item: function (frm) { + frappe.call({ + method: "erpnext.manufacturing.doctype.work_order.mapper.get_fg_conversion_details", + args: { work_order: frm.doc.name }, + callback: function (r) { + if (!r.message.alternative_items.length) { + frappe.msgprint( + __( + "Please create Item Alternative records for the item {0} to change the finished item.", + [frappe.utils.get_form_link("Item", frm.doc.production_item, true)] + ) + ); + return; + } + + if (!flt(r.message.available_qty)) { + frappe.msgprint( + __("The produced qty of the item {0} has already been converted in full.", [ + frm.doc.production_item.bold(), + ]) + ); + return; + } + + frm.events.show_change_finished_item_dialog(frm, r.message); + }, + }); + }, + + show_change_finished_item_dialog: function (frm, { alternative_items, available_qty }) { + const dialog = new frappe.ui.Dialog({ + title: __("Change Finished Item"), + fields: [ + { + fieldtype: "Link", + fieldname: "item_code", + label: __("Actual Finished Item"), + options: "Item", + reqd: 1, + default: alternative_items.length === 1 ? alternative_items[0] : undefined, + get_query: () => { + return { filters: { name: ["in", alternative_items] } }; + }, + }, + { + fieldtype: "Float", + fieldname: "qty", + label: __("Qty to Convert"), + reqd: 1, + default: available_qty, + description: __("Available produced qty of the item {0} is {1}.", [ + frm.doc.production_item.bold(), + cstr(available_qty).bold(), + ]), + }, + ], + primary_action_label: __("Create Stock Entry"), + primary_action: (values) => { + dialog.hide(); + frappe.call({ + method: "erpnext.manufacturing.doctype.work_order.mapper.make_fg_conversion_entry", + args: { + work_order: frm.doc.name, + item_code: values.item_code, + qty: values.qty, + }, + callback: function (r) { + if (!r.exc) { + let doc = frappe.model.sync(r.message); + frappe.set_route("Form", doc[0].doctype, doc[0].name); + } + }, + }); + }, + }); + + dialog.show(); + }, + create_stock_return_entry: function (frm) { frappe.call({ method: "erpnext.manufacturing.doctype.work_order.mapper.make_stock_return_entry", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 428c274d5cd..6ef1f7cf709 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -187,6 +187,9 @@ class WorkOrder(Document): 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("transfer_extra_materials_percentage", ms.transfer_extra_materials_percentage) + self.set_onload("allow_alternative_finished_goods", ms.allow_alternative_finished_goods) + if ms.allow_alternative_finished_goods and self.docstatus == 1 and flt(self.produced_qty): + self.set_onload("has_alternative_finished_goods", self.has_alternative_finished_goods()) self.set_onload("show_create_job_card_button", self.show_create_job_card_button()) self.set_onload( "enable_stock_reservation", @@ -197,6 +200,14 @@ class WorkOrder(Document): if based_on := frappe.get_cached_value("BOM", self.bom_no, "backflush_based_on"): self.set_onload("backflush_raw_materials_based_on", based_on) + def has_alternative_finished_goods(self): + return bool( + frappe.db.exists("Item Alternative", {"item_code": self.production_item}) + or frappe.db.exists( + "Item Alternative", {"alternative_item_code": self.production_item, "two_way": 1} + ) + ) + @property def secondary_items(self): parent = frappe.qb.DocType("Stock Entry") diff --git a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py index 8d3770805e6..fbe0a409868 100644 --- a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py +++ b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py @@ -16,8 +16,15 @@ def execute(filters=None): if not filters.get("age"): filters["age"] = 0 + show_actual_finished_goods = frappe.db.get_single_value( + "Manufacturing Settings", "allow_alternative_finished_goods" + ) + data = get_data(filters) - columns = get_columns(filters) + if show_actual_finished_goods: + set_actual_finished_goods(data) + + columns = get_columns(filters, show_actual_finished_goods) chart_data = get_chart_data(data, filters) return columns, data, None, chart_data @@ -75,6 +82,49 @@ def get_data(filters): return res +def set_actual_finished_goods(data): + work_orders = [d.name for d in data if flt(d.produced_qty)] + if not work_orders: + return + + conversion_rows = get_fg_conversion_rows(work_orders) + if not conversion_rows: + return + + converted_qty, alternative_fg = defaultdict(float), defaultdict(lambda: defaultdict(float)) + production_items = {d.name: d.production_item for d in data} + + for row in conversion_rows: + if row.is_finished_item: + alternative_fg[row.work_order][row.item_code] += flt(row.transfer_qty) + elif row.item_code == production_items.get(row.work_order): + converted_qty[row.work_order] += flt(row.transfer_qty) + + for d in data: + if d.name not in alternative_fg: + continue + + outputs = [(d.production_item, flt(d.produced_qty) - converted_qty[d.name])] + outputs.extend(sorted(alternative_fg[d.name].items())) + d.actual_finished_goods = ", ".join( + f"{item_code}: {flt(qty)}" for item_code, qty in outputs if flt(qty) + ) + + +def get_fg_conversion_rows(work_orders): + se = frappe.qb.DocType("Stock Entry") + sed = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(se) + .inner_join(sed) + .on(sed.parent == se.name) + .select(se.work_order, sed.item_code, sed.transfer_qty, sed.is_finished_item) + .where((se.docstatus == 1) & (se.is_fg_conversion == 1) & se.work_order.isin(work_orders)) + .run(as_dict=True) + ) + + def get_chart_data(data, filters): if filters.get("charts_based_on") == "Status": return get_chart_based_on_status(data) @@ -186,7 +236,7 @@ def prepare_chart_data(data, filters): return labels, periodic_data -def get_columns(filters): +def get_columns(filters, show_actual_finished_goods=False): columns = [ { "label": _("Id"), @@ -213,6 +263,21 @@ def get_columns(filters): }, {"label": _("Produce Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 110}, {"label": _("Produced Qty"), "fieldname": "produced_qty", "fieldtype": "Float", "width": 110}, + ] + ) + + if show_actual_finished_goods: + columns.append( + { + "label": _("Actual Finished Goods"), + "fieldname": "actual_finished_goods", + "fieldtype": "Data", + "width": 200, + } + ) + + columns.extend( + [ { "label": _("Sales Order"), "fieldname": "sales_order", diff --git a/erpnext/stock/doctype/stock_entry/services/manufacturing.py b/erpnext/stock/doctype/stock_entry/services/manufacturing.py index d1b5178c95b..625a56c36a3 100644 --- a/erpnext/stock/doctype/stock_entry/services/manufacturing.py +++ b/erpnext/stock/doctype/stock_entry/services/manufacturing.py @@ -982,6 +982,89 @@ class RepackStockEntry(BaseManufactureStockEntry): self.validate_raw_materials_exists() self.validate_repack_entry() + def validate_fg_conversion(self): + if not self.doc.is_fg_conversion: + return + + if not frappe.db.get_single_value("Manufacturing Settings", "allow_alternative_finished_goods"): + frappe.throw( + _( + "Enable 'Allow Alternative Finished Goods' in Manufacturing Settings to make a finished good conversion entry." + ) + ) + + if not self.wo_doc: + frappe.throw(_("Work Order is mandatory for a finished good conversion entry.")) + + self._validate_work_order() + self.validate_alternative_finished_goods() + self.validate_conversion_qty() + + def validate_alternative_finished_goods(self): + production_item = self.wo_doc.production_item + alternative_items = get_alternative_finished_goods(production_item) + if not alternative_items: + frappe.throw( + _( + "Please create Item Alternative records for the item {0} to change the finished item." + ).format(get_link_to_form("Item", production_item)) + ) + + for row in self.doc.items: + if row.is_finished_item and row.item_code not in alternative_items: + frappe.throw( + _("Row #{0}: Item {1} is not an alternative item of the production item {2}.").format( + row.idx, bold(row.item_code), bold(production_item) + ) + ) + + def validate_conversion_qty(self): + production_item = self.wo_doc.production_item + consumed_qty = sum( + flt(row.transfer_qty) + for row in self.doc.items + if row.s_warehouse and row.item_code == production_item + ) + + if not consumed_qty: + frappe.throw( + _( + "A finished good conversion entry must consume the production item {0} of the Work Order {1}." + ).format(bold(production_item), get_link_to_form("Work Order", self.doc.work_order)) + ) + + self.validate_conversion_output_qty(consumed_qty, production_item) + + is_submitting = self.doc.docstatus == 1 + produced_qty = flt( + frappe.db.get_value("Work Order", self.doc.work_order, "produced_qty", for_update=is_submitting) + ) + available_qty = produced_qty - get_converted_fg_qty( + self.doc.work_order, exclude=self.doc.name, for_update=is_submitting + ) + if consumed_qty > available_qty: + frappe.throw( + _( + "The qty {0} of the item {1} to convert cannot be more than the available produced qty {2} against the Work Order {3}." + ).format( + consumed_qty, + bold(production_item), + available_qty, + get_link_to_form("Work Order", self.doc.work_order), + ) + ) + + def validate_conversion_output_qty(self, consumed_qty, production_item): + precision = self.doc.precision("fg_completed_qty") + output_qty = sum(flt(row.transfer_qty) for row in self.doc.items if row.is_finished_item) + + if flt(output_qty, precision) != flt(consumed_qty, precision): + frappe.throw( + _( + "The total qty {0} of the alternative finished goods must be equal to the converted qty {1} of the production item {2}." + ).format(output_qty, consumed_qty, bold(production_item)) + ) + def validate_repack_entry(self): fg_items = {row.item_code: row for row in self.doc.items if row.is_finished_item} @@ -1529,3 +1612,44 @@ def _cap_sample_quantity(sample_quantity, max_retain_qty, retainted_qty, batch_n ) return qty_diff return sample_quantity + + +def get_alternative_finished_goods(production_item): + alternatives = frappe.get_all( + "Item Alternative", filters={"item_code": production_item}, pluck="alternative_item_code" + ) + alternatives += frappe.get_all( + "Item Alternative", + filters={"alternative_item_code": production_item, "two_way": 1}, + pluck="item_code", + ) + return list(dict.fromkeys(alternatives)) + + +def get_converted_fg_qty(work_order, exclude=None, for_update=False): + production_item = frappe.db.get_value("Work Order", work_order, "production_item") + + se = frappe.qb.DocType("Stock Entry") + sed = frappe.qb.DocType("Stock Entry Detail") + query = ( + frappe.qb.from_(se) + .inner_join(sed) + .on(sed.parent == se.name) + .select(sed.transfer_qty) + .where( + (se.work_order == work_order) + & (se.is_fg_conversion == 1) + & (se.docstatus == 1) + & (sed.item_code == production_item) + & sed.s_warehouse.notnull() + & (sed.s_warehouse != "") + ) + ) + + if exclude: + query = query.where(se.name != exclude) + + if for_update: + query = query.for_update() + + return sum(flt(row[0]) for row in query.run()) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 3e305ada502..493df49bf09 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -22,6 +22,7 @@ "inspection_required", "column_break_jabv", "work_order", + "is_fg_conversion", "subcontracting_order", "outgoing_stock_entry", "source_stock_entry", @@ -162,7 +163,7 @@ "reqd": 1 }, { - "depends_on": "eval:in_list([\"Material Transfer for Manufacture\", \"Manufacture\", \"Material Consumption for Manufacture\", \"Disassemble\"], doc.purpose)", + "depends_on": "eval:in_list([\"Material Transfer for Manufacture\", \"Manufacture\", \"Material Consumption for Manufacture\", \"Disassemble\"], doc.purpose) || doc.is_fg_conversion", "fieldname": "work_order", "fieldtype": "Link", "label": "Work Order", @@ -172,6 +173,16 @@ "print_hide": 1, "search_index": 1 }, + { + "default": "0", + "fieldname": "is_fg_conversion", + "fieldtype": "Check", + "hidden": 1, + "label": "Is Finished Good Conversion", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, { "depends_on": "eval: erpnext.stock.is_subcontracting_or_return_transfer(doc)", "fieldname": "purchase_order", @@ -773,7 +784,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-06-11 18:23:12.340065", + "modified": "2026-08-27 10:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index fc510b9b582..d16dc1b42c7 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -116,6 +116,7 @@ class StockEntry(StockController, SubcontractingInwardController): from_warehouse: DF.Link | None inspection_required: DF.Check is_additional_transfer_entry: DF.Check + is_fg_conversion: DF.Check is_opening: DF.Literal["No", "Yes"] is_return: DF.Check items: DF.Table[StockEntryDetail] @@ -321,6 +322,13 @@ class StockEntry(StockController, SubcontractingInwardController): else: self.validate_job_card_fg_item() + # Must run after set_transfer_qty() and mark_finished_and_secondary_items() so the + # qty parity and conversion cap checks see recomputed transfer_qty on edited rows. + if self.is_fg_conversion: + if self.purpose != "Repack": + frappe.throw(_("A finished good conversion entry must have the purpose 'Repack'.")) + self.purpose_cls(self).validate_fg_conversion() + # Disassembly rows are fully derived from the source manufacture entry / work order; # verify the posted stock quantities have not been tampered with (raw-material minting). # Must run after set_transfer_qty() so row.transfer_qty reflects qty * conversion_factor. @@ -975,7 +983,7 @@ class StockEntry(StockController, SubcontractingInwardController): for d in self.get("items"): if d.is_finished_item: - if not self.work_order: + if not self.work_order or self.is_fg_conversion: # Independent MFG Entry/ Repack Entry, no WO to match against finished_items.append(d.item_code) continue @@ -1547,13 +1555,18 @@ class StockEntry(StockController, SubcontractingInwardController): return 0 def set_work_order_details(self): - if self.work_order: - # common validations - if self.pro_doc and not self.pro_doc.track_semi_finished_goods: - self.bom_no = self.pro_doc.bom_no - else: - # invalid work order - self.work_order = None + if not self.work_order: + return + + if self.pro_doc and self.is_fg_conversion: + return + + # common validations + if self.pro_doc and not self.pro_doc.track_semi_finished_goods: + self.bom_no = self.pro_doc.bom_no + else: + # invalid work order + self.work_order = None def get_bom_raw_materials(self, qty): from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict