diff --git a/erpnext/stock/doctype/stock_entry/services/disassemble.py b/erpnext/stock/doctype/stock_entry/services/disassemble.py index 3c94ea5771f..21f29ed41a1 100644 --- a/erpnext/stock/doctype/stock_entry/services/disassemble.py +++ b/erpnext/stock/doctype/stock_entry/services/disassemble.py @@ -2,7 +2,7 @@ from collections import defaultdict import frappe from frappe import _ -from frappe.query_builder.functions import Max, Min, NullIf, Sum +from frappe.query_builder.functions import Min, NullIf, Sum from frappe.utils import flt from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -348,35 +348,15 @@ class DisassembleStockEntry(BaseStockEntry): .run(as_dict=True) ) - # Aggregating across all Manufacture entries of the work order, one row per item_code. - # The non-grouped columns are constant per item_code in practice (an item plays one role with - # one uom/warehouse across the WO's manufacture entries); Max() keeps the GROUP BY valid on - # postgres while returning the value MySQL picked arbitrarily, preserving the one-row-per-item - # shape the disassembly expects. - return ( + # qty-weighted average so consolidating an item across manufacture entries at different + # valuation rates values the summed qty correctly. + # Manufacture rows always carry positive qty, so NullIf only guards a theoretical /0. + rows = ( query.select( + SED.item_code, Sum(SED.qty).as_("qty"), Sum(SED.transfer_qty).as_("transfer_qty"), - SED.item_code, - Max(SED.item_name).as_("item_name"), - Max(SED.description).as_("description"), - Max(SED.stock_uom).as_("stock_uom"), - Max(SED.uom).as_("uom"), - # qty-weighted average so consolidating an item across manufacture entries at different - # valuation rates values the summed qty correctly (Max would bias the rate high). - # Manufacture rows always carry positive qty, so NullIf only guards a theoretical /0. (Sum(SED.basic_rate * SED.qty) / NullIf(Sum(SED.qty), 0)).as_("basic_rate"), - Max(SED.conversion_factor).as_("conversion_factor"), - Max(SED.is_finished_item).as_("is_finished_item"), - Max(SED.secondary_item_type).as_("secondary_item_type"), - Max(SED.is_legacy_scrap_item).as_("is_legacy_scrap_item"), - Max(SED.bom_secondary_item).as_("bom_secondary_item"), - Max(SED.batch_no).as_("batch_no"), - Max(SED.serial_no).as_("serial_no"), - Max(SED.use_serial_batch_fields).as_("use_serial_batch_fields"), - Max(SED.s_warehouse).as_("s_warehouse"), - Max(SED.t_warehouse).as_("t_warehouse"), - Max(SED.bom_no).as_("bom_no"), ) .where(SE.purpose == "Manufacture") .where(SE.work_order == self.doc.work_order) @@ -385,6 +365,60 @@ class DisassembleStockEntry(BaseStockEntry): .run(as_dict=True) ) + representative = self.get_representative_manufacture_rows() + for row in rows: + row.update(representative.get(row.item_code) or {}) + + return rows + + def get_representative_manufacture_rows(self): + """Earliest posted line per item across the work order's Manufacture entries. + + The disassembly wants one row per item, but the descriptive columns describe a line, not + an item: uom only means something beside its conversion_factor, batch_no and serial_no + beside their warehouse, and is_finished_item decides whether the row is the output or an + input. Aggregating each column on its own can pair values from different lines into a row + that was never posted, so take the columns from a single real line instead. + """ + SE = frappe.qb.DocType("Stock Entry") + SED = frappe.qb.DocType("Stock Entry Detail") + + lines = ( + frappe.qb.from_(SED) + .join(SE) + .on(SED.parent == SE.name) + .select( + SED.item_code, + SED.item_name, + SED.description, + SED.stock_uom, + SED.uom, + SED.conversion_factor, + SED.is_finished_item, + SED.secondary_item_type, + SED.is_legacy_scrap_item, + SED.bom_secondary_item, + SED.batch_no, + SED.serial_no, + SED.use_serial_batch_fields, + SED.s_warehouse, + SED.t_warehouse, + SED.bom_no, + ) + .where( + (SE.docstatus == 1) & (SE.purpose == "Manufacture") & (SE.work_order == self.doc.work_order) + ) + .orderby(SE.creation) + .orderby(SED.idx) + .run(as_dict=True) + ) + + representative = {} + for line in lines: + representative.setdefault(line.item_code, line) + + return representative + def on_submit(self): self.set_serial_batch_for_disassembly() self.update_disassembled_order()