From b3113ac160c4a52e6ba6e56808b2d177bfe5d8cb Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 2 Aug 2026 19:55:12 +0530 Subject: [PATCH] fix(stock): take disassembly source columns from one posted line get_items_from_manufacture_stock_entry collapses a work order's Manufacture entries to one row per item and wrapped fifteen Stock Entry Detail columns in independent Max() to satisfy Postgres' strict GROUP BY. Those columns describe a line, not an item, and three sets have to stay together: uom only means something beside its conversion_factor batch_no and serial_no only beside their warehouse is_finished_item decides whether the row is the output or an input Aggregated separately they can be drawn from different lines. Two Manufacture entries consuming the same item in Nos and in Box return ("Nos", 5) -- a pair that was never posted, and one that does not describe the summed quantity. Keep the sums (and the qty-weighted basic_rate) in the aggregate, and read the descriptive columns off a single real line: the earliest by Stock Entry creation then idx. That is what MariaDB returned in practice, it is deterministic, and it is identical on both engines. Same representative-row shape already used by BOM Stock Analysis and the sub-assembly queries. --- .../stock_entry/services/disassemble.py | 86 +++++++++++++------ 1 file changed, 60 insertions(+), 26 deletions(-) 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()