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
This commit is contained in:
rohitwaghchaure
2026-08-28 12:56:06 +05:30
committed by GitHub
parent 9160182727
commit 1f7f8cd9d3
10 changed files with 507 additions and 13 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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",

View File

@@ -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")

View File

@@ -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",

View File

@@ -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())

View File

@@ -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",

View File

@@ -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