mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-29 06:38:24 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user