mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 22:51:49 +00:00
Merge pull request #57716 from frappe/pg-audit/collation-representative-lines
fix(postgres): read BOM/SO/MR line columns off one line, not Max()
This commit is contained in:
@@ -51,7 +51,6 @@ def get_data(filters):
|
||||
mr_item.item_code.as_("item_code"),
|
||||
Sum(Coalesce(mr_item.qty, 0)).as_("qty"),
|
||||
Sum(Coalesce(mr_item.stock_qty, 0)).as_("stock_qty"),
|
||||
Max(Coalesce(mr_item.uom, "")).as_("uom"),
|
||||
Max(Coalesce(mr_item.stock_uom, "")).as_("stock_uom"),
|
||||
Sum(Coalesce(mr_item.ordered_qty, 0)).as_("ordered_qty"),
|
||||
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
|
||||
@@ -60,8 +59,6 @@ def get_data(filters):
|
||||
),
|
||||
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
|
||||
(Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.ordered_qty, 0))).as_("qty_to_order"),
|
||||
Max(mr_item.item_name).as_("item_name"),
|
||||
Max(mr_item.description).as_("description"),
|
||||
Max(mr.company).as_("company"),
|
||||
)
|
||||
.where(
|
||||
@@ -75,8 +72,34 @@ def get_data(filters):
|
||||
query = get_conditions(filters, query, mr, mr_item) # add conditional conditions
|
||||
|
||||
query = query.groupby(mr.name, mr_item.item_code).orderby(Max(mr.transaction_date), Max(mr.schedule_date))
|
||||
data = query.run(as_dict=True)
|
||||
return data
|
||||
rows = query.run(as_dict=True)
|
||||
apply_representative_lines(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def apply_representative_lines(rows):
|
||||
"""Fill item_name/description/uom from one real Material Request Item line per group.
|
||||
|
||||
All three are editable per line, so a request listing the same item twice holds several values
|
||||
per group. Aggregating them sorts text, and MariaDB folds case while PostgreSQL orders by byte
|
||||
value, so the engines pick differently. Take the first line by idx.
|
||||
"""
|
||||
material_requests = list({row.material_request for row in rows})
|
||||
representative = {}
|
||||
if material_requests:
|
||||
for line in frappe.get_all(
|
||||
"Material Request Item",
|
||||
filters={"parent": ("in", material_requests), "docstatus": 1},
|
||||
fields=["parent", "item_code", "item_name", "description", "uom"],
|
||||
order_by="idx",
|
||||
):
|
||||
representative.setdefault((line.parent, line.item_code), line)
|
||||
|
||||
for row in rows:
|
||||
line = representative.get((row.material_request, row.item_code))
|
||||
row.item_name = line.item_name if line else None
|
||||
row.description = line.description if line else None
|
||||
row.uom = line.uom if line else ""
|
||||
|
||||
|
||||
def get_conditions(filters, query, mr, mr_item):
|
||||
|
||||
@@ -11,6 +11,7 @@ from frappe.model.document import Document
|
||||
from frappe.query_builder import Field
|
||||
from frappe.query_builder.functions import Count, IfNull, Max, Min, NullIf, Sum
|
||||
from frappe.utils import cint, cstr, flt, get_link_to_form, parse_json
|
||||
from frappe.utils.caching import request_cache
|
||||
from frappe.website.website_generator import WebsiteGenerator
|
||||
|
||||
import erpnext
|
||||
@@ -1208,7 +1209,65 @@ def _query_bom_items(bom, company, opts):
|
||||
query, group_by = _add_bom_item_columns(query, t, bom, opts, track_semi_finished_goods)
|
||||
# qualify + aggregate idx: bare "idx" is ambiguous across the joined tables and isn't grouped
|
||||
# (idx is unique per BOM item, so Min() preserves the original ordering) — needed for postgres
|
||||
return query.groupby(*group_by).orderby(Min(t.bom_item.idx)).run(as_dict=True)
|
||||
rows = query.groupby(*group_by).orderby(Min(t.bom_item.idx)).run(as_dict=True)
|
||||
|
||||
if not opts.fetch_secondary_items:
|
||||
doctype = "BOM Explosion Item" if cint(opts.fetch_exploded) else "BOM Item"
|
||||
# key only on group-by columns that belong to the line table. stock_uom is grouped from Item
|
||||
# and can differ from the line's stored copy once an item's stock UOM is changed after the
|
||||
# BOM was submitted; keying on it would miss and blank the row. It is functionally dependent
|
||||
# on item_code anyway, so dropping it from the key loses nothing.
|
||||
keys = [field.name for field in group_by if field.table is t.bom_item]
|
||||
_apply_representative_lines(rows, doctype, bom, keys)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _line_columns_for(doctype):
|
||||
columns = ["description", "source_warehouse"]
|
||||
if doctype == "BOM Item":
|
||||
# uom only means something beside its own conversion_factor, so they travel together
|
||||
columns += ["uom", "conversion_factor"]
|
||||
return columns
|
||||
|
||||
|
||||
def _apply_representative_lines(rows, doctype, bom, keys):
|
||||
"""Fill the line-level columns from a single real BOM line per group.
|
||||
|
||||
They describe a line, not an item, so a BOM listing the same item more than once holds several
|
||||
values per group. Aggregating each independently can pair one line's description with another's
|
||||
warehouse -- or a uom with the wrong conversion_factor -- and Max() over text is a sort, which
|
||||
MariaDB (case-folding) and PostgreSQL (byte order) resolve differently. Take the first by idx.
|
||||
"""
|
||||
repeated = [row for row in rows if (row.pop("line_count", 1) or 1) > 1]
|
||||
if not repeated:
|
||||
return
|
||||
|
||||
columns = _line_columns_for(doctype)
|
||||
representative = _representative_lines(doctype, bom, tuple(keys), tuple(columns))
|
||||
|
||||
for row in repeated:
|
||||
line = representative.get(tuple(row.get(key) for key in keys))
|
||||
if not line:
|
||||
continue
|
||||
for column in columns:
|
||||
row[column] = line.get(column)
|
||||
|
||||
|
||||
@request_cache
|
||||
def _representative_lines(doctype, bom, keys, columns):
|
||||
"""Cached per request: get_bom_items_as_dict recurses through phantom BOMs, and the same
|
||||
sub-BOM is commonly reached more than once."""
|
||||
representative = {}
|
||||
for line in frappe.get_all(
|
||||
doctype,
|
||||
filters={"parent": bom, "parenttype": "BOM", "docstatus": ("<", 2)},
|
||||
fields=[*keys, *columns],
|
||||
order_by="idx",
|
||||
):
|
||||
representative.setdefault(tuple(line.get(key) for key in keys), line)
|
||||
|
||||
return representative
|
||||
|
||||
|
||||
def _get_bom_item_tables(opts):
|
||||
@@ -1290,10 +1349,11 @@ def _add_exploded_item_columns(query, t, bom, amount_col, stock_item_condition):
|
||||
# keeping the GROUP BY postgres-valid; the correlated idx subquery references only item_code
|
||||
# (a grouped column) so it stays valid and still overrides the explosion idx for display.
|
||||
query = query.select(
|
||||
Max(t.bom_item.description).as_("description"),
|
||||
Max(t.bom_item.source_warehouse).as_("source_warehouse"),
|
||||
Count(t.bom_item.name).distinct().as_("line_count"),
|
||||
Max(t.bom_item.operation).as_("operation"),
|
||||
Max(t.bom_item.include_item_in_manufacturing).as_("include_item_in_manufacturing"),
|
||||
Max(t.bom_item.description).as_("description"),
|
||||
Max(t.bom_item.rate).as_("rate"),
|
||||
Max(t.bom_item.sourced_by_supplier).as_("sourced_by_supplier"),
|
||||
amount_col,
|
||||
@@ -1329,14 +1389,15 @@ def _add_normal_item_columns(query, t, amount_col, stock_item_condition, track_s
|
||||
# under the same alias and silently shadowed (last value wins in the dict), so it is dropped here
|
||||
# -- output is unchanged.
|
||||
query = query.select(
|
||||
Max(t.bom_item.uom).as_("uom"),
|
||||
Max(t.bom_item.conversion_factor).as_("conversion_factor"),
|
||||
Max(t.bom_item.description).as_("description"),
|
||||
Max(t.bom_item.source_warehouse).as_("source_warehouse"),
|
||||
Count(t.bom_item.name).distinct().as_("line_count"),
|
||||
Max(t.bom_item.operation).as_("operation"),
|
||||
Max(t.bom_item.include_item_in_manufacturing).as_("include_item_in_manufacturing"),
|
||||
Max(t.bom_item.sourced_by_supplier).as_("sourced_by_supplier"),
|
||||
Max(t.bom_item.uom).as_("uom"),
|
||||
Max(t.bom_item.conversion_factor).as_("conversion_factor"),
|
||||
amount_col,
|
||||
Max(t.bom_item.description).as_("description"),
|
||||
Max(t.bom_item.base_rate).as_("rate"),
|
||||
Max(t.bom_item.operation_row_id).as_("operation_row_id"),
|
||||
t.bom_item.is_phantom_item,
|
||||
|
||||
@@ -128,6 +128,43 @@ class TestBOM(ERPNextTestSuite):
|
||||
self.assertEqual(len([row for row in items_dict if row == rm.name]), 1)
|
||||
self.assertAlmostEqual(flt(items_dict[rm.name].amount), expected, places=2)
|
||||
|
||||
@timeout
|
||||
def test_get_items_takes_line_columns_from_one_line(self):
|
||||
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
rm = make_item(properties={"is_stock_item": 1, "valuation_rate": 10})
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
|
||||
first_warehouse = create_warehouse("_Test BOM Line A")
|
||||
second_warehouse = create_warehouse("_Test BOM Line B")
|
||||
|
||||
bom = make_bom(item=fg_item, raw_materials=[rm.name], rm_qty=2, do_not_save=True)
|
||||
bom.items[0].description = "bbb first line"
|
||||
bom.items[0].source_warehouse = first_warehouse
|
||||
bom.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": rm.name,
|
||||
"qty": 3,
|
||||
"uom": rm.stock_uom,
|
||||
"stock_uom": rm.stock_uom,
|
||||
"description": "ccc second line",
|
||||
"source_warehouse": second_warehouse,
|
||||
},
|
||||
)
|
||||
bom.save()
|
||||
bom.submit()
|
||||
|
||||
items_dict = get_bom_items_as_dict(bom.name, "_Test Company", qty=1, fetch_exploded=0)
|
||||
row = items_dict[rm.name]
|
||||
|
||||
# "ccc" sorts above "bbb" on either engine, so an aggregated description would win here;
|
||||
# the value must instead come from the first line, together with that line's warehouse
|
||||
self.assertEqual(row.description, "bbb first line")
|
||||
self.assertEqual(row.source_warehouse, first_warehouse)
|
||||
|
||||
@timeout
|
||||
def test_default_bom(self):
|
||||
def _get_default_bom_in_item():
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"""BOM explosion helpers for Production Plan material planning."""
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import IfNull, Max, Min, Sum
|
||||
from frappe.query_builder.functions import Count, IfNull, Max, Min, Sum
|
||||
from frappe.utils.caching import request_cache
|
||||
|
||||
from erpnext.manufacturing.doctype.production_plan.services.planning_queries import get_uom_conversion_factor
|
||||
|
||||
@@ -21,7 +22,7 @@ def _exploded_items_query(company, bom_no, include_non_stock_items, planned_qty)
|
||||
item = frappe.qb.DocType("Item")
|
||||
item_default = frappe.qb.DocType("Item Default")
|
||||
item_uom = frappe.qb.DocType("UOM Conversion Detail")
|
||||
return (
|
||||
rows = (
|
||||
frappe.qb.from_(bei)
|
||||
.join(bom)
|
||||
.on(bom.name == bei.parent)
|
||||
@@ -36,19 +37,92 @@ def _exploded_items_query(company, bom_no, include_non_stock_items, planned_qty)
|
||||
.groupby(bei.item_code, bei.stock_uom)
|
||||
).run(as_dict=True)
|
||||
|
||||
_apply_representative_lines(
|
||||
rows, "BOM Explosion Item", bom_no, ("item_code", "stock_uom"), include_non_stock_items
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _apply_representative_lines(rows, doctype, bom_no, keys, include_non_stock_items=True):
|
||||
"""Fill description/source_warehouse from a single real BOM line per group.
|
||||
|
||||
Both describe a line, not an item, so a BOM listing the same item more than once holds
|
||||
several values per group. Aggregating each independently can pair one line's description
|
||||
with another's warehouse, and Max() over text is a sort -- MariaDB folds case, PostgreSQL
|
||||
orders by byte value, so the two engines pick differently. Take the first line by idx.
|
||||
|
||||
Only groups built from more than one line need this. Where a group has a single line, Max() of
|
||||
one value is that value, so the selected columns are already exact and no query is issued --
|
||||
which matters because this runs once per BOM in a recursive explosion.
|
||||
"""
|
||||
repeated = [row for row in rows if (row.pop("line_count", 1) or 1) > 1]
|
||||
if not repeated:
|
||||
return
|
||||
|
||||
representative = _representative_lines(doctype, bom_no, tuple(keys), include_non_stock_items)
|
||||
|
||||
for row in repeated:
|
||||
line = representative.get(tuple(row.get(key) for key in keys))
|
||||
if line:
|
||||
row.description = line.description
|
||||
row.source_warehouse = line.source_warehouse
|
||||
|
||||
|
||||
@request_cache
|
||||
def _representative_lines(doctype, bom_no, keys, include_non_stock_items):
|
||||
"""Cached per request: the explosion recurses and commonly revisits the same sub-BOM."""
|
||||
# only BOM Item carries is_phantom_item, and only its query ORs the phantom flag into the stock
|
||||
# filter; the explosion table has neither
|
||||
filters_phantom = doctype == "BOM Item"
|
||||
fields = ["item_code", "stock_uom", "description", "source_warehouse"]
|
||||
if filters_phantom:
|
||||
fields.append("is_phantom_item")
|
||||
|
||||
lines = frappe.get_all(
|
||||
doctype,
|
||||
filters={
|
||||
"parent": bom_no,
|
||||
"parenttype": "BOM",
|
||||
"is_sub_assembly_item": 0,
|
||||
"docstatus": ("<", 2),
|
||||
},
|
||||
fields=fields,
|
||||
order_by="idx",
|
||||
)
|
||||
|
||||
# mirror the caller's stock filter: a non-stock line the main query excluded must not become
|
||||
# the representative for a group that only exists because of a phantom line
|
||||
if not include_non_stock_items and filters_phantom and lines:
|
||||
stock_items = set(
|
||||
frappe.get_all(
|
||||
"Item",
|
||||
filters={"name": ("in", list({line.item_code for line in lines})), "is_stock_item": 1},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
lines = [line for line in lines if line.item_code in stock_items or line.is_phantom_item]
|
||||
|
||||
representative = {}
|
||||
for line in lines:
|
||||
representative.setdefault(tuple(line.get(key) for key in keys), line)
|
||||
|
||||
return representative
|
||||
|
||||
|
||||
def _exploded_item_columns(bei, bom, item, item_default, item_uom, planned_qty):
|
||||
# only item_code/stock_uom are grouped; the rest are functionally dependent on the grouped item
|
||||
# or arbitrary per BOM Item on MySQL -> Max() keeps the GROUP BY valid on postgres with the same
|
||||
# value MySQL picked.
|
||||
# every column here is functionally dependent on the grouped item_code -- Item, Item Default and
|
||||
# UOM Conversion Detail are joined on it and the BOM is pinned by the filter -- so Max() returns
|
||||
# their single value. The BOM-line columns come from a representative line instead; see
|
||||
# _apply_representative_lines.
|
||||
return [
|
||||
(IfNull(Sum(bei.stock_qty / IfNull(bom.quantity, 1)), 0) * planned_qty).as_("qty"),
|
||||
Max(item.item_name).as_("item_name"),
|
||||
Max(item.name).as_("item_code"),
|
||||
Max(bei.description).as_("description"),
|
||||
Max(bei.source_warehouse).as_("source_warehouse"),
|
||||
Count(bei.name).distinct().as_("line_count"),
|
||||
bei.stock_uom,
|
||||
Max(item.min_order_qty).as_("min_order_qty"),
|
||||
Max(bei.source_warehouse).as_("source_warehouse"),
|
||||
Max(item.default_material_request_type).as_("default_material_request_type"),
|
||||
Max(item.min_order_qty).as_("min_order_qty"),
|
||||
Max(item_default.default_warehouse).as_("default_warehouse"),
|
||||
@@ -96,7 +170,7 @@ def _subitems_query(company, bom_no, include_non_stock_items, parent_qty, planne
|
||||
item = frappe.qb.DocType("Item")
|
||||
item_default = frappe.qb.DocType("Item Default")
|
||||
item_uom = frappe.qb.DocType("UOM Conversion Detail")
|
||||
return (
|
||||
rows = (
|
||||
frappe.qb.from_(bom_item)
|
||||
.join(bom)
|
||||
.on(bom.name == bom_item.parent)
|
||||
@@ -113,6 +187,9 @@ def _subitems_query(company, bom_no, include_non_stock_items, parent_qty, planne
|
||||
.orderby(Min(bom_item.idx))
|
||||
).run(as_dict=True)
|
||||
|
||||
_apply_representative_lines(rows, "BOM Item", bom_no, ("item_code",), include_non_stock_items)
|
||||
return rows
|
||||
|
||||
|
||||
def _subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, planned_qty):
|
||||
qty = IfNull(parent_qty * Sum(bom_item.stock_qty / IfNull(bom.quantity, 1)) * planned_qty, 0).as_("qty")
|
||||
@@ -128,9 +205,10 @@ def _subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, pl
|
||||
Max(item.item_name).as_("item_name"),
|
||||
qty,
|
||||
Max(item.is_sub_contracted_item).as_("is_sub_contracted"),
|
||||
Max(bom_item.source_warehouse).as_("source_warehouse"),
|
||||
Max(item.default_bom).as_("default_bom"),
|
||||
Max(bom_item.description).as_("description"),
|
||||
Max(bom_item.source_warehouse).as_("source_warehouse"),
|
||||
Count(bom_item.name).distinct().as_("line_count"),
|
||||
Max(item.default_bom).as_("default_bom"),
|
||||
Max(bom_item.stock_uom).as_("stock_uom"),
|
||||
Max(item.min_order_qty).as_("min_order_qty"),
|
||||
Max(item.safety_stock).as_("safety_stock"),
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
"""Sub-assembly resolution helpers for Production Plan."""
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import IfNull, Max, Sum
|
||||
from frappe.query_builder.functions import Count, IfNull, Max, Sum
|
||||
from frappe.utils import flt
|
||||
from frappe.utils.caching import request_cache
|
||||
|
||||
from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children
|
||||
from erpnext.manufacturing.doctype.production_plan.services.planning_queries import (
|
||||
@@ -167,7 +168,7 @@ def _sub_assembly_rm_query(company, bom_no, include_non_stock_items, planned_qty
|
||||
item = frappe.qb.DocType("Item")
|
||||
item_default = frappe.qb.DocType("Item Default")
|
||||
item_uom = frappe.qb.DocType("UOM Conversion Detail")
|
||||
return (
|
||||
rows = (
|
||||
frappe.qb.from_(bei)
|
||||
.join(bom)
|
||||
.on(bom.name == bei.parent)
|
||||
@@ -182,6 +183,50 @@ def _sub_assembly_rm_query(company, bom_no, include_non_stock_items, planned_qty
|
||||
.groupby(bei.item_code, bei.stock_uom, bei.bom_no, bei.is_phantom_item)
|
||||
).run(as_dict=True)
|
||||
|
||||
_apply_representative_lines(rows, bom_no)
|
||||
return rows
|
||||
|
||||
|
||||
def _apply_representative_lines(rows, bom_no):
|
||||
"""Fill description/source_warehouse from a single real BOM Item line per group.
|
||||
|
||||
Both describe a line, not an item. Aggregating each independently can pair one line's
|
||||
description with another's warehouse, and Max() over text is a sort -- MariaDB folds case,
|
||||
PostgreSQL orders by byte value, so the engines pick differently. Take the first line by idx.
|
||||
"""
|
||||
repeated = [row for row in rows if (row.pop("line_count", 1) or 1) > 1]
|
||||
if not repeated:
|
||||
return
|
||||
|
||||
keys = ("item_code", "stock_uom", "bom_no", "is_phantom_item")
|
||||
representative = _representative_lines(bom_no, keys)
|
||||
|
||||
for row in repeated:
|
||||
line = representative.get(tuple(row.get(key) for key in keys))
|
||||
if line:
|
||||
row.description = line.description
|
||||
row.source_warehouse = line.source_warehouse
|
||||
|
||||
|
||||
@request_cache
|
||||
def _representative_lines(bom_no, keys):
|
||||
"""Cached per request: sub-assembly resolution recurses and revisits the same BOM."""
|
||||
representative = {}
|
||||
for line in frappe.get_all(
|
||||
"BOM Item",
|
||||
filters={
|
||||
"parent": bom_no,
|
||||
"parenttype": "BOM",
|
||||
"is_sub_assembly_item": 0,
|
||||
"docstatus": 1,
|
||||
},
|
||||
fields=["item_code", "stock_uom", "bom_no", "is_phantom_item", "description", "source_warehouse"],
|
||||
order_by="idx",
|
||||
):
|
||||
representative.setdefault(tuple(line.get(key) for key in keys), line)
|
||||
|
||||
return representative
|
||||
|
||||
|
||||
def _sub_assembly_rm_columns(bei, bom, item, item_default, item_uom, planned_qty):
|
||||
# Grouped by item_code/stock_uom plus bom_no/is_phantom_item: those two MUST come from the same
|
||||
@@ -195,11 +240,12 @@ def _sub_assembly_rm_columns(bei, bom, item, item_default, item_uom, planned_qty
|
||||
Max(item.item_name).as_("item_name"),
|
||||
Max(item.name).as_("item_code"),
|
||||
Max(bei.description).as_("description"),
|
||||
Max(bei.source_warehouse).as_("source_warehouse"),
|
||||
Count(bei.name).distinct().as_("line_count"),
|
||||
bei.stock_uom,
|
||||
bei.is_phantom_item,
|
||||
bei.bom_no,
|
||||
Max(item.min_order_qty).as_("min_order_qty"),
|
||||
Max(bei.source_warehouse).as_("source_warehouse"),
|
||||
Max(item.default_material_request_type).as_("default_material_request_type"),
|
||||
Max(item.min_order_qty).as_("min_order_qty"),
|
||||
Max(item_default.default_warehouse).as_("default_warehouse"),
|
||||
|
||||
@@ -134,15 +134,15 @@ def get_data_without_qty_to_make(filters):
|
||||
for row in raw_rows:
|
||||
data.append(
|
||||
{
|
||||
"item": row[0],
|
||||
"description": row[1],
|
||||
"from_bom_no": row[2],
|
||||
"qty_per_unit": fmt_qty(row[3]),
|
||||
"available_qty": fmt_qty(row[4]),
|
||||
"item": row.item_code,
|
||||
"description": row.description,
|
||||
"from_bom_no": row.from_bom_no,
|
||||
"qty_per_unit": fmt_qty(row.qty_per_unit),
|
||||
"available_qty": fmt_qty(row.available_qty),
|
||||
}
|
||||
)
|
||||
|
||||
min_producible = min((row[5] or 0) for row in raw_rows) if raw_rows else 0
|
||||
min_producible = min((row.producible_qty or 0) for row in raw_rows) if raw_rows else 0
|
||||
# blank spacer row
|
||||
data.append({})
|
||||
|
||||
@@ -237,7 +237,6 @@ def get_bom_data(filters):
|
||||
.select(
|
||||
bom_item.item_code,
|
||||
# non-grouped columns are constant per grouped item_code -> Max() keeps the GROUP BY valid
|
||||
Max(bom_item.description).as_("description"),
|
||||
Max(bom_item.parent).as_("from_bom_no"),
|
||||
Sum(bom_item.qty_consumed_per_unit).as_("qty_per_unit"),
|
||||
IfNull(Max(stock_qty.actual_qty), 0).as_("actual_qty"),
|
||||
@@ -249,28 +248,36 @@ def get_bom_data(filters):
|
||||
|
||||
data = query.run(as_dict=True)
|
||||
|
||||
# description belongs to a BOM line, not to the item, so a component listed more than once holds
|
||||
# several values per group. Max() over text is a sort and the engines sort text differently
|
||||
# (MariaDB folds case, PostgreSQL orders by byte value), so read it off one real line instead.
|
||||
# For BOM Item that same line also supplies bom_no + is_phantom_item, which drive whether and
|
||||
# which sub-BOM explode_phantom_boms recurses into and so must stay coherent with each other:
|
||||
# the first line, upgraded to the first phantom line if any exists, so a phantom sub-BOM is never
|
||||
# dropped just because a non-phantom line happens to be listed first.
|
||||
fields = ["item_code", "description"]
|
||||
if bom_item_table == "BOM Item":
|
||||
fields += ["bom_no", "is_phantom_item"]
|
||||
|
||||
representative = {}
|
||||
for line in frappe.get_all(
|
||||
bom_item_table,
|
||||
filters={"parent": filters.get("bom"), "parenttype": "BOM"},
|
||||
fields=fields,
|
||||
order_by="idx",
|
||||
):
|
||||
existing = representative.get(line.item_code)
|
||||
if existing is None or (line.get("is_phantom_item") and not existing.get("is_phantom_item")):
|
||||
representative[line.item_code] = line
|
||||
|
||||
for row in data:
|
||||
line = representative.get(row.item_code)
|
||||
row.description = line.description if line else None
|
||||
if bom_item_table == "BOM Item":
|
||||
row.bom_no = line.bom_no if line else None
|
||||
row.is_phantom_item = line.is_phantom_item if line else None
|
||||
|
||||
if bom_item_table == "BOM Item":
|
||||
# bom_no + is_phantom_item drive whether/which sub-BOM explode_phantom_boms recurses into, so
|
||||
# they must come from the SAME BOM Item line. Aggregating each independently (Max) could pair a
|
||||
# bom_no from one line with is_phantom_item from another when an item_code repeats in the BOM.
|
||||
# Rows are grouped by item_code (one qty_per_unit total per component), so pick one coherent
|
||||
# representative line: the first line, but upgrade to the first phantom line if any exists, so a
|
||||
# phantom sub-BOM is never dropped just because a non-phantom line happens to be listed first.
|
||||
representative = {}
|
||||
for line in frappe.get_all(
|
||||
"BOM Item",
|
||||
filters={"parent": filters.get("bom"), "parenttype": "BOM"},
|
||||
fields=["item_code", "bom_no", "is_phantom_item"],
|
||||
order_by="idx",
|
||||
):
|
||||
existing = representative.get(line.item_code)
|
||||
if existing is None or (line.is_phantom_item and not existing.is_phantom_item):
|
||||
representative[line.item_code] = line
|
||||
for row in data:
|
||||
line = representative.get(row.item_code)
|
||||
if line:
|
||||
row.bom_no = line.bom_no
|
||||
row.is_phantom_item = line.is_phantom_item
|
||||
return explode_phantom_boms(data, filters)
|
||||
|
||||
return data
|
||||
@@ -351,15 +358,37 @@ def get_producible_fg_items(filters):
|
||||
BOM_ITEM.item_code,
|
||||
# Sum() below makes this an aggregate query; the other columns are constant per grouped
|
||||
# item_code -> Max() keeps them valid on postgres with the same value MySQL picked.
|
||||
Max(BOM_ITEM.description).as_("description"),
|
||||
# description is not: it belongs to the line, so it comes from a representative one below.
|
||||
Max(BOM_ITEM.parent).as_("from_bom_no"),
|
||||
Max(BOM_ITEM.stock_qty / BOM.quantity).as_("qty_per_unit"),
|
||||
Max(IfNull(bin_subquery.actual_qty, 0)).as_("available_qty"),
|
||||
Floor(Max(bin_subquery.actual_qty) / ((Sum(BOM_ITEM.stock_qty)) / Max(BOM.quantity))),
|
||||
Floor(Max(bin_subquery.actual_qty) / ((Sum(BOM_ITEM.stock_qty)) / Max(BOM.quantity))).as_(
|
||||
"producible_qty"
|
||||
),
|
||||
)
|
||||
.where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM"))
|
||||
.groupby(BOM_ITEM.item_code)
|
||||
.orderby(Min(BOM_ITEM.idx))
|
||||
)
|
||||
|
||||
return query.run(as_list=True)
|
||||
rows = query.run(as_dict=True)
|
||||
descriptions = get_representative_descriptions("BOM Item", filters.get("bom"))
|
||||
for row in rows:
|
||||
row.description = descriptions.get(row.item_code)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def get_representative_descriptions(doctype, bom):
|
||||
"""First line by idx per item_code. description belongs to a line, not an item, so aggregating it
|
||||
sorts text -- and MariaDB folds case while PostgreSQL orders by byte value."""
|
||||
descriptions = {}
|
||||
for line in frappe.get_all(
|
||||
doctype,
|
||||
filters={"parent": bom, "parenttype": "BOM"},
|
||||
fields=["item_code", "description"],
|
||||
order_by="idx",
|
||||
):
|
||||
descriptions.setdefault(line.item_code, line.description)
|
||||
|
||||
return descriptions
|
||||
|
||||
@@ -49,6 +49,29 @@ def get_columns():
|
||||
return columns
|
||||
|
||||
|
||||
def apply_representative_lines(rows, sales_orders):
|
||||
"""Fill item_name/description from one real Sales Order Item line per group.
|
||||
|
||||
Both are editable per line, so an order listing the same item twice holds several values per
|
||||
group. Aggregating them sorts text, and MariaDB folds case while PostgreSQL orders by byte
|
||||
value, so the engines pick differently. Take the first line by idx.
|
||||
"""
|
||||
representative = {}
|
||||
if sales_orders:
|
||||
for line in frappe.get_all(
|
||||
"Sales Order Item",
|
||||
filters={"parent": ("in", sales_orders), "docstatus": 1},
|
||||
fields=["parent", "item_code", "item_name", "description"],
|
||||
order_by="idx",
|
||||
):
|
||||
representative.setdefault((line.parent, line.item_code), line)
|
||||
|
||||
for row in rows:
|
||||
line = representative.get((row.name, row.item_code))
|
||||
row.item_name = line.item_name if line else None
|
||||
row.description = line.description if line else None
|
||||
|
||||
|
||||
def get_data():
|
||||
so = frappe.qb.DocType("Sales Order")
|
||||
so_item = frappe.qb.DocType("Sales Order Item")
|
||||
@@ -58,10 +81,9 @@ def get_data():
|
||||
.on(so.name == so_item.parent)
|
||||
.select(
|
||||
so_item.item_code,
|
||||
# non-grouped columns are constant per grouped so.name / item_code -> Max() keeps the
|
||||
# GROUP BY valid on postgres while returning the same value MySQL picked.
|
||||
Max(so_item.item_name).as_("item_name"),
|
||||
Max(so_item.description).as_("description"),
|
||||
# the Sales Order columns are functionally dependent on the grouped so.name, so Max()
|
||||
# returns their single value. item_name/description belong to the line and are editable
|
||||
# per line, so they come from a representative line below.
|
||||
so.name,
|
||||
Max(so.transaction_date).as_("transaction_date"),
|
||||
Max(so.customer).as_("customer"),
|
||||
@@ -75,6 +97,7 @@ def get_data():
|
||||
)
|
||||
|
||||
sales_orders = [row.name for row in sales_order_entry]
|
||||
apply_representative_lines(sales_order_entry, sales_orders)
|
||||
mr_records = frappe.get_all(
|
||||
"Material Request Item",
|
||||
{"sales_order": ("in", sales_orders), "docstatus": 1},
|
||||
|
||||
@@ -1007,11 +1007,9 @@ def get_secondary_items_from_job_card(work_order, jc_name=None):
|
||||
.select(
|
||||
Sum(job_card_secondary_item.stock_qty).as_("stock_qty"),
|
||||
job_card_secondary_item.item_code,
|
||||
# non-grouped columns are item attributes / the secondary-item BOM link, constant per
|
||||
# grouped (item_code, secondary_item_type) -> Max() keeps the GROUP BY valid on postgres
|
||||
# while returning the value MySQL picked arbitrarily.
|
||||
Max(job_card_secondary_item.item_name).as_("item_name"),
|
||||
Max(job_card_secondary_item.description).as_("description"),
|
||||
# stock_uom and the secondary-item BOM link are constant per grouped
|
||||
# (item_code, secondary_item_type) -> Max() returns their single value. item_name and
|
||||
# description are editable per line, so they come from a representative line below.
|
||||
Max(job_card_secondary_item.stock_uom).as_("stock_uom"),
|
||||
job_card_secondary_item.secondary_item_type,
|
||||
Max(job_card_secondary_item.bom_secondary_item).as_("bom_secondary_item"),
|
||||
@@ -1030,7 +1028,41 @@ def get_secondary_items_from_job_card(work_order, jc_name=None):
|
||||
if jc_name:
|
||||
secondary_items = secondary_items.where(job_card.name == jc_name)
|
||||
|
||||
return secondary_items.run(as_dict=1)
|
||||
rows = secondary_items.run(as_dict=1)
|
||||
apply_representative_secondary_lines(rows, work_order, jc_name)
|
||||
return rows
|
||||
|
||||
|
||||
def apply_representative_secondary_lines(rows, work_order, jc_name=None):
|
||||
"""Fill item_name/description from one real Job Card Secondary Item line per group.
|
||||
|
||||
Both are editable per line, so the same secondary item across a work order's job cards can
|
||||
carry several values per group. Aggregating them sorts text, and MariaDB folds case while
|
||||
PostgreSQL orders by byte value, so the engines pick differently.
|
||||
"""
|
||||
job_cards = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": work_order, "docstatus": 1, **({"name": jc_name} if jc_name else {})},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
representative = {}
|
||||
if job_cards:
|
||||
for line in frappe.get_all(
|
||||
"Job Card Secondary Item",
|
||||
filters={"parent": ("in", job_cards)},
|
||||
# idx first, so the rule really is "first by idx"; creation breaks ties across job cards.
|
||||
# Never order by parent -- the Job Card name is text, and sorting text is the divergence
|
||||
# this is here to avoid.
|
||||
fields=["item_code", "secondary_item_type", "item_name", "description"],
|
||||
order_by="idx, creation",
|
||||
):
|
||||
representative.setdefault((line.item_code, line.secondary_item_type), line)
|
||||
|
||||
for row in rows:
|
||||
line = representative.get((row.item_code, row.secondary_item_type))
|
||||
row.item_name = line.item_name if line else None
|
||||
row.description = line.description if line else None
|
||||
|
||||
|
||||
def get_previous_operation_output_sn_batch(work_order, item_code, warehouse):
|
||||
|
||||
Reference in New Issue
Block a user