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.
This commit is contained in:
Mihir Kandoi
2026-08-02 19:55:12 +05:30
parent 2181e957d2
commit b3113ac160

View File

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