diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 15b2c59e614c2878e51a3f3ce8fbbb2ca8eb152f..1b28724102a5edc998395c20181ddd761b9207fa 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -17,6 +17,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( ) from erpnext.accounts.party import get_due_date, get_party_account from erpnext.controllers.queries import item_query as _item_query +from erpnext.stock.doctype.packed_item.packed_item import get_product_bundle, get_product_bundle_name from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -324,7 +325,9 @@ class POSInvoice(SalesInvoice): if is_negative_stock_allowed(item_code=d.item_code): return - available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse) + available_stock, is_stock_item = get_stock_availability( + d.item_code, d.warehouse, d.product_bundle_name + ) item_code, warehouse, qty = ( frappe.bold(d.item_code), @@ -745,7 +748,7 @@ class POSInvoice(SalesInvoice): return False @frappe.whitelist() -def get_stock_availability(item_code, warehouse): +def get_stock_availability(item_code, warehouse, product_bundle_name=""): if frappe.db.get_value("Item", item_code, "is_stock_item"): is_stock_item = True bin_qty = get_bin_qty(item_code, warehouse) @@ -754,16 +757,18 @@ def get_stock_availability(item_code, warehouse): return bin_qty - pos_sales_qty, is_stock_item else: is_stock_item = True - if frappe.db.exists("Product Bundle", {"name": item_code, "disabled": 0}): - return get_bundle_availability(item_code, warehouse), is_stock_item + if get_product_bundle_name(item_code, product_bundle_name=product_bundle_name): + return get_bundle_availability( + item_code, warehouse, product_bundle_name=product_bundle_name + ), is_stock_item else: is_stock_item = False # Is a service item or non_stock item return 0, is_stock_item -def get_bundle_availability(bundle_item_code, warehouse): - product_bundle = frappe.get_doc("Product Bundle", bundle_item_code) +def get_bundle_availability(bundle_item_code, warehouse, product_bundle_name=""): + product_bundle = get_product_bundle(bundle_item_code, product_bundle_name=product_bundle_name) bundle_bin_qty = 1000000 for item in product_bundle.items: @@ -777,7 +782,7 @@ def get_bundle_availability(bundle_item_code, warehouse): ): bundle_bin_qty = max_available_bundles - pos_sales_qty = get_pos_reserved_qty(bundle_item_code, warehouse) + pos_sales_qty = get_pos_reserved_qty(product_bundle.new_item_code, warehouse) return bundle_bin_qty - pos_sales_qty diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index aaa832f00ccb495361220815cd156b11b8a8909d..41e61101bac962b17987479e8d1066a53072e1dd 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -103,6 +103,7 @@ "sales_order", "so_detail", "sales_invoice_item", + "product_bundle_name", "column_break_74", "delivery_note", "dn_detail", @@ -1001,6 +1002,14 @@ "options": "POS Invoice", "print_hide": 1, "search_index": 1 + }, + { + "fieldname": "product_bundle_name", + "fieldtype": "Link", + "hidden": 1, + "label": "Product Bundle", + "options": "Product Bundle", + "read_only": 1 } ], "grid_page_length": 50, diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py index 7c68cbec29ad8047ce7d10e4f4d7b4e675dbc829..d0b1d2165c0e859c1c45479f48388bf9469a3fd4 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py @@ -71,6 +71,7 @@ class SalesInvoiceItem(Document): pos_invoice_item: DF.Data | None price_list_rate: DF.Currency pricing_rules: DF.SmallText | None + product_bundle_name: DF.Link | None project: DF.Link | None purchase_order: DF.Link | None purchase_order_item: DF.Data | None diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 894ad38a59ccb9bc6805838b247666713abb4afe..e5353f955a76af940abb6eb7f47cf309a13d20ed 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -57,7 +57,7 @@ from erpnext.controllers.sales_and_purchase_return import validate_return from erpnext.exceptions import InvalidCurrency from erpnext.setup.utils import get_exchange_rate from erpnext.stock.doctype.item.item import get_uom_conv_factor -from erpnext.stock.doctype.packed_item.packed_item import make_packing_list +from erpnext.stock.doctype.packed_item.packed_item import get_product_bundle_name, make_packing_list from erpnext.stock.get_item_details import ( _get_item_tax_template, get_conversion_factor, @@ -3581,6 +3581,13 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if new_child_flag: child_item.fg_item = d["fg_item"] + if parent.doctype == "Sales Order": + if product_bundle_name := get_product_bundle_name( + child_item.item_code, + product_bundle_name=child_item.product_bundle_name, + ): + child_item.product_bundle_name = product_bundle_name + child_item.qty = flt(d.get("qty")) rate_precision = child_item.precision("rate") or 2 conv_fac_precision = child_item.precision("conversion_factor") or 2 @@ -3703,6 +3710,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ) else: # Sales Order parent.validate_for_duplicate_items() + parent.validate_items_product_bundle() parent.validate_warehouse() parent.update_reserved_qty() parent.update_project() @@ -3732,6 +3740,16 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if parent.per_picked == 0: parent.create_stock_reservation_entries() + # Check that old packed items were not already ordered + # old_packed_items = (parent.get_doc_before_save() or {}).get("packed_items") or [] + # new_packed_items = parent.get("packed_items") + # print(old_packed_items, new_packed_items) + # removed = {pi.name for pi in old_packed_items} - {pi.name for pi in new_packed_items} + # if frappe.db.exists("Purchase Order Item", {"sales_order_packed_item": ("in", removed), "docstatus": 1}): + # frappe.throw("Cannot edit Product Bundle already ordered in Purchase Order") + + # frappe.throw("lorem") + def check_if_child_table_updated( child_table_before_update, child_table_after_update, fields_to_check diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index f7e6356ee0d1c52c27c3b6f8c3c7e4166e8fd14b..750445b705337dced831d66339a93655993d05f9 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -11,6 +11,7 @@ from erpnext.controllers.accounts_controller import get_taxes_and_charges from erpnext.controllers.sales_and_purchase_return import get_rate_for_return from erpnext.controllers.stock_controller import StockController from erpnext.stock.doctype.item.item import set_item_default +from erpnext.stock.doctype.packed_item.packed_item import get_product_bundle from erpnext.stock.get_item_details import get_bin_details, get_conversion_factor, GROSS_PROFIT_CALCULATION_RULES from erpnext.stock.utils import get_incoming_rate, get_valuation_method @@ -34,6 +35,7 @@ class SellingController(StockController): def validate(self): super().validate() self.validate_items() + self.validate_items_product_bundle() if not (self.get("is_debit_note") or self.get("is_return")): self.validate_max_discount() self.validate_selling_price() @@ -320,7 +322,7 @@ class SellingController(StockController): if d.qty is None: frappe.throw(_("Row {0}: Qty is mandatory").format(d.idx)) - if self.has_product_bundle(d.item_code): + if d.product_bundle_name or self.has_product_bundle(d.item_code): for p in self.get("packed_items"): if p.parent_detail_docname == d.name and p.parent_item == d.item_code: # the packing details table's qty is already multiplied with parent's qty @@ -829,6 +831,18 @@ class SellingController(StockController): validate_item_type(self, "is_sales_item", _("sales")) + def validate_items_product_bundle(self): + for item in self.items: + bundle = None + if self.has_product_bundle(item.item_code): + bundle = get_product_bundle(item.item_code, product_bundle_name=item.product_bundle_name) + if not bundle or bundle.new_item_code != item.item_code: + # The row item and the bundle's item are mismatched, override the product_bundle_name + bundle = get_product_bundle(item.item_code) # Fetch any bundle of the correct item + + # If there is a bundle, write its name, if not clear the field. + item.product_bundle_name = bundle.name if bundle else None + def validate_recurring_items(self): if self.recurrence_period: for item in self.items: diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.json b/erpnext/selling/doctype/product_bundle/product_bundle.json index 811ebb7d004ee498bfba58b150871adab8d13852..790e77cd50d958ad01e5200f598e3f34729a74af 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.json +++ b/erpnext/selling/doctype/product_bundle/product_bundle.json @@ -1,6 +1,7 @@ { "actions": [], "allow_import": 1, + "allow_rename": 1, "creation": "2013-06-20 11:53:21", "description": "Aggregate a group of Items into another Item. This is useful if you are maintaining the stock of the packed items and not the bundled item", "doctype": "DocType", @@ -36,7 +37,7 @@ }, { "fieldname": "description", - "fieldtype": "Data", + "fieldtype": "Text Editor", "in_list_view": 1, "label": "Description" }, @@ -84,7 +85,7 @@ "icon": "fa fa-sitemap", "idx": 1, "links": [], - "modified": "2025-01-21 11:50:07.516599", + "modified": "2025-03-06 11:46:14.675103", "modified_by": "Administrator", "module": "Selling", "name": "Product Bundle", @@ -122,5 +123,6 @@ ], "sort_field": "modified", "sort_order": "DESC", - "states": [] + "states": [], + "title_field": "new_item_code" } diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.py b/erpnext/selling/doctype/product_bundle/product_bundle.py index eba763fa96638b84b074f206ad0e6403cf21f87d..af7f6852b7ef0ee6ecdd4b69c98272bad817385e 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/product_bundle.py @@ -8,6 +8,8 @@ from frappe.model.document import Document from frappe.query_builder import Criterion from frappe.utils import get_link_to_form +from erpnext.stock.doctype.packed_item.packed_item import get_product_bundle_name + class ProductBundle(Document): # begin: auto-generated types @@ -20,7 +22,7 @@ class ProductBundle(Document): from erpnext.selling.doctype.product_bundle_item.product_bundle_item import ProductBundleItem - description: DF.Data | None + description: DF.TextEditor | None disabled: DF.Check editable_bundle_item_rates: DF.Check items: DF.Table[ProductBundleItem] @@ -29,6 +31,8 @@ class ProductBundle(Document): def autoname(self): self.name = self.new_item_code + if frappe.db.exists(self.doctype, self.name): + self.name = f"{self.new_item_code}-{frappe.generate_hash(length=8)}" def validate(self): self.validate_main_item() @@ -82,7 +86,7 @@ class ProductBundle(Document): def validate_child_items(self): for item in self.items: - if frappe.db.exists("Product Bundle", {"name": item.item_code, "disabled": 0}): + if get_product_bundle_name(item.item_code): frappe.throw( _( "Row #{0}: Child Item should not be a Product Bundle. Please remove Item {1} and Save" @@ -93,7 +97,7 @@ class ProductBundle(Document): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_new_item_code(doctype, txt, searchfield, start, page_len, filters): - product_bundles = frappe.db.get_list("Product Bundle", {"disabled": 0}, pluck="name") + # product_bundles = frappe.db.get_list("Product Bundle", {"disabled": 0}, pluck="name") if not searchfield or searchfield == "name": searchfield = frappe.get_meta("Item").get("search_fields") @@ -115,7 +119,7 @@ def get_new_item_code(doctype, txt, searchfield, start, page_len, filters): Criterion.any([item[fieldname.strip()].like(f"%{txt}%") for fieldname in searchfield]) ) - if product_bundles: - query = query.where(item.name.notin(product_bundles)) + # if product_bundles: + # query = query.where(item.name.notin(product_bundles)) return query.run() diff --git a/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json b/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json index a810be95e4073bace8afb0a85429c8bfaaa24ef9..15b4e89df20bda2d0b779e288a54b662910ade9b 100644 --- a/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json +++ b/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json @@ -69,7 +69,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2024-05-08 10:08:01.818998", + "modified": "2025-02-14 13:41:35.762125", "modified_by": "Administrator", "module": "Selling", "name": "Product Bundle Item", diff --git a/erpnext/selling/doctype/product_bundle_item/product_bundle_item.py b/erpnext/selling/doctype/product_bundle_item/product_bundle_item.py index 7b3d97469626b52c4f946ee496b9a29187ab545f..830b07ec529b30406baaa267f8a3038ca0c07ca7 100644 --- a/erpnext/selling/doctype/product_bundle_item/product_bundle_item.py +++ b/erpnext/selling/doctype/product_bundle_item/product_bundle_item.py @@ -7,4 +7,22 @@ from frappe.model.document import Document class ProductBundleItem(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + description: DF.TextEditor | None + item_code: DF.Link + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + qty: DF.Float + rate: DF.Float + uom: DF.Link | None + # end: auto-generated types + pass diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json index f122f75c5784c3ad9de51b846a5900b86bf1cc28..de4bbaa7a09a80fcd948699892a168a87188ce73 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.json +++ b/erpnext/selling/doctype/quotation_item/quotation_item.json @@ -83,6 +83,7 @@ "against_blanket_order", "blanket_order", "blanket_order_rate", + "product_bundle_name", "column_break_30", "prevdoc_doctype", "prevdoc_docname", @@ -731,8 +732,8 @@ "fieldname": "last_purchase_rate", "fieldtype": "Currency", "label": "Last Purchase Rate", - "read_only": 1, - "print_hide": 1 + "print_hide": 1, + "read_only": 1 }, { "fieldname": "supplier", @@ -804,8 +805,8 @@ "fieldname": "unit_cost_price", "fieldtype": "Currency", "label": "Unit Cost Price", - "read_only": 1, - "print_hide": 1 + "print_hide": 1, + "read_only": 1 }, { "description": "Set a custom mark-up percentage to recalculate your item selling rate", @@ -813,12 +814,20 @@ "fieldtype": "Percent", "label": "Mark-up percentage", "print_hide": 1 + }, + { + "fieldname": "product_bundle_name", + "fieldtype": "Link", + "hidden": 1, + "label": "Product Bundle", + "options": "Product Bundle", + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2024-12-12 13:49:17.765883", + "modified": "2025-02-14 14:53:07.282841", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Item", diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.py b/erpnext/selling/doctype/quotation_item/quotation_item.py index a10d08bc88c26e8e434616e9049077f9927a88d4..27949a4991de3a8efe648615559d57174938fb94 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.py +++ b/erpnext/selling/doctype/quotation_item/quotation_item.py @@ -67,6 +67,7 @@ class QuotationItem(Document): prevdoc_doctype: DF.Link | None price_list_rate: DF.Currency pricing_rules: DF.SmallText | None + product_bundle_name: DF.Link | None projected_qty: DF.Float qty: DF.Float rate: DF.Currency diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 8a68010fb81f17489be2298fa38bf19d613f1799..6903e2bbe0f23990f6839a7436c51e9f891b7480 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -1399,8 +1399,9 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex // calculate ordered qty based on packed items in case of product bundle let packed_items = so.packed_items.filter((pi) => pi.parent_detail_docname == item.name); if (packed_items && packed_items.length) { - ordered_qty = packed_items.reduce((sum, pi) => sum + flt(pi.ordered_qty), 0); - ordered_qty = ordered_qty / packed_items.length; + const pi_qty = packed_items.reduce((sum, pi) => sum + flt(pi.qty), 0); + const pi_ordered_qty = packed_items.reduce((sum, pi) => sum + flt(pi.ordered_qty), 0); + ordered_qty = item.stock_qty * (pi_ordered_qty / pi_qty); } } return ordered_qty; diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 5089b73ee30e4ba0f2c6d6cc60e7b24ca57307ba..5f503669c7c60f99506a4af99936953d22d6a397 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -367,7 +367,10 @@ class SalesOrder(SellingController): if ( ( frappe.get_cached_value("Item", d.item_code, "is_stock_item") == 1 - or (self.has_product_bundle(d.item_code) and self.product_bundle_has_stock_item(d.item_code)) + or ( + self.has_product_bundle(d.item_code) + and self.product_bundle_has_stock_item(d.product_bundle_name or d.item_code) + ) ) and not d.warehouse and not cint(d.delivered_by_supplier) @@ -543,7 +546,7 @@ class SalesOrder(SellingController): for d in self.get("items"): if (not so_item_rows or d.name in so_item_rows) and not d.delivered_by_supplier: - if self.has_product_bundle(d.item_code): + if d.product_bundle_name or self.has_product_bundle(d.item_code): for p in self.get("packed_items"): if p.parent_detail_docname == d.name and p.parent_item == d.item_code: _valid_for_reserve(p.item_code, p.warehouse) @@ -617,9 +620,11 @@ class SalesOrder(SellingController): per_picked = 0.0 for so_item in self.items: - if cint( - frappe.get_cached_value("Item", so_item.item_code, "is_stock_item") - ) or self.has_product_bundle(so_item.item_code): + if ( + cint(frappe.get_cached_value("Item", so_item.item_code, "is_stock_item")) + or so_item.product_bundle_name + or self.has_product_bundle(so_item.item_code) + ): total_picked_qty += flt(so_item.picked_qty) total_qty += flt(so_item.stock_qty) @@ -980,9 +985,9 @@ def make_material_request(source_name, target_doc=None): "delivery_date": "required_by", "bom_no": "bom_no", }, - "condition": lambda item: item.item_code and not frappe.db.exists( - "Product Bundle", {"name": item.item_code, "disabled": 0} - ) # @dokos + "condition": lambda item: not is_product_bundle( + item.item_code, product_bundle_name=item.get("product_bundle_name") + ) and get_remaining_qty(item) > 0, "postprocess": update_item, }, @@ -1676,7 +1681,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): "postprocess": update_item, "condition": lambda doc: doc.ordered_qty < doc.stock_qty and doc.item_code in items_to_map - and not is_product_bundle(doc.item_code), + and not is_product_bundle(doc.item_code, product_bundle_name=doc.get("product_bundle_name")), }, "Packed Item": { "doctype": "Purchase Order Item", @@ -1724,8 +1729,10 @@ def set_delivery_date(items, sales_order): item.schedule_date = delivery_by_item[item.product_bundle] -def is_product_bundle(item_code): - return frappe.db.exists("Product Bundle", {"name": item_code, "disabled": 0}) +def is_product_bundle(item_code="", *, product_bundle_name=""): + from erpnext.stock.doctype.packed_item.packed_item import get_product_bundle_name + + return get_product_bundle_name(item_code, product_bundle_name=product_bundle_name) @frappe.whitelist() @@ -1836,7 +1843,7 @@ def make_inter_company_purchase_order(source_name, target_doc=None): @frappe.whitelist() def create_pick_list(source_name, target_doc=None): - from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle + from erpnext.stock.doctype.packed_item.packed_item import get_product_bundle_name def validate_sales_order(): so = frappe.get_doc("Sales Order", source_name) @@ -1869,7 +1876,7 @@ def create_pick_list(source_name, target_doc=None): item.item_code # @dokos and abs(item.delivered_qty) < abs(item.qty) and item.delivered_by_supplier != 1 - and not is_product_bundle(item.item_code) + and not get_product_bundle_name(item.item_code, product_bundle_name=item.product_bundle_name) ) # Don't allow a Pick List to be created against a Sales Order that has reserved stock. @@ -1939,12 +1946,11 @@ def get_work_order_items(sales_order, for_raw_material_request=0): items = [] item_codes = [i.item_code for i in so.items] - product_bundle_parents = [ - pb.new_item_code - for pb in frappe.get_all( - "Product Bundle", {"new_item_code": ["in", item_codes], "disabled": 0}, ["new_item_code"] - ) - ] + product_bundle_parents = frappe.get_all( + "Product Bundle", + {"new_item_code": ["in", item_codes], "disabled": 0}, + pluck="new_item_code", + ) for table in [so.items, so.packed_items]: for i in table: diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index d362bffba1002a19bf140023fd7931f230e4c44e..f8af58089fac7b9c16581b663fb63e6bd8ffa8f5 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -92,6 +92,7 @@ "target_warehouse", "quotation_item", "prevdoc_docname", + "product_bundle_name", "col_break4", "against_blanket_order", "blanket_order", @@ -1040,15 +1041,15 @@ "fieldname": "unit_cost_price", "fieldtype": "Currency", "label": "Unit Cost Price", - "read_only": 1, - "print_hide": 1 + "print_hide": 1, + "read_only": 1 }, { "fieldname": "last_purchase_rate", "fieldtype": "Currency", "label": "Last Purchase Rate", - "read_only": 1, - "print_hide": 1 + "print_hide": 1, + "read_only": 1 }, { "fieldname": "accounting_dimensions_section", @@ -1071,6 +1072,14 @@ "label": "Cost Center", "options": "Cost Center", "print_hide": 1 + }, + { + "fieldname": "product_bundle_name", + "fieldtype": "Link", + "hidden": 1, + "label": "Product Bundle", + "options": "Product Bundle", + "read_only": 1 } ], "idx": 1, diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.py b/erpnext/selling/doctype/sales_order_item/sales_order_item.py index c785adf08dc09852ee77944db69aba09b9020cda..b289c1b68df88c20ee29cebb70790c568a70c36b 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.py +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.py @@ -79,6 +79,7 @@ class SalesOrderItem(Document): price_list_rate: DF.Currency pricing_rules: DF.SmallText | None produced_qty: DF.Float + product_bundle_name: DF.Link | None production_plan_qty: DF.Float project: DF.Link | None projected_qty: DF.Float diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py index fcc19332949b418e1cb46361acf837ab7812f9d9..89c9d5ff285c41d5b92069033a311376da5866c4 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -55,7 +55,12 @@ def search_by_term(search_term, warehouse, price_list): } ) - item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse) + # Get last product bundle + product_bundle_name = frappe.db.get_value("Product Bundle", {"new_item_code": item_code, "disabled": 0}) + + item_stock_qty, is_stock_item = get_stock_availability( + item_code, warehouse, product_bundle_name=product_bundle_name + ) item_stock_qty = item_stock_qty // item.get("conversion_factor", 1) item.update({"actual_qty": item_stock_qty}) diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index 7b9f10ceb540fcb9cc358b5b73917f19a70b3d21..5fa1050cd011f34295deebba633b094d7a9259a3 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -371,8 +371,9 @@ erpnext.PointOfSale.Controller = class { this.cart.prev_action = null; this.cart.toggle_item_highlight(); }, - get_available_stock: (item_code, warehouse) => this.get_available_stock(item_code, warehouse) - } + get_available_stock: (item_code, warehouse, product_bundle_name) => + this.get_available_stock(item_code, warehouse, product_bundle_name), + }, }); } @@ -711,7 +712,9 @@ erpnext.PointOfSale.Controller = class { } async check_stock_availability(item_row, qty_needed, warehouse) { - const resp = (await this.get_available_stock(item_row.item_code, warehouse)).message; + const resp = ( + await this.get_available_stock(item_row.item_code, warehouse, item_row.product_bundle_name) + ).message; const available_qty = resp[0]; const is_stock_item = resp[1]; @@ -753,13 +756,14 @@ erpnext.PointOfSale.Controller = class { } } - get_available_stock(item_code, warehouse) { + get_available_stock(item_code, warehouse, product_bundle_name) { const me = this; return frappe.call({ method: "erpnext.accounts.doctype.pos_invoice.pos_invoice.get_stock_availability", args: { 'item_code': item_code, 'warehouse': warehouse, + 'product_bundle_name': product_bundle_name, }, callback(res) { if (!me.item_stock_map[item_code]) diff --git a/erpnext/selling/page/point_of_sale/pos_item_details.js b/erpnext/selling/page/point_of_sale/pos_item_details.js index 27d17c69d10ab66dfccbbee1150bde8f559ed796..c6ed813d9f0a7da274d2b96318d05aded685c55c 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_details.js +++ b/erpnext/selling/page/point_of_sale/pos_item_details.js @@ -261,7 +261,7 @@ erpnext.PointOfSale.ItemDetails = class { const available_qty = stock_map?.[0]; const is_stock_item = Boolean(stock_map?.[1]); if (available_qty === undefined) { - me.events.get_available_stock(me.item_row.item_code, this.value).then(() => { + me.events.get_available_stock(me.item_row.item_code, this.value, me.item_row.product_bundle_name).then(() => { // item stock map is updated now reset warehouse me.warehouse_control.set_value(this.value); }) diff --git a/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py b/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py index 680e379651b50cb8ffe96ac815beefb06fe801b4..b4182dcb3ec908f668db48af2ecc93d884dff1a7 100644 --- a/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py +++ b/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py @@ -81,9 +81,7 @@ def get_data(): bundled_item_map = get_packed_items(sales_orders) - item_with_product_bundle = get_items_with_product_bundle( - [row.item_code for row in sales_order_entry] - ) + item_with_product_bundle = get_items_with_product_bundle([row.item_code for row in sales_order_entry]) materials_request_dict = {} @@ -143,12 +141,12 @@ def get_data(): def get_items_with_product_bundle(item_list): - bundled_items = frappe.get_all( - "Product Bundle", filters=[("new_item_code", "IN", item_list)], fields=["new_item_code"] + return frappe.get_all( + "Product Bundle", + filters=[("new_item_code", "IN", item_list)], + pluck="new_item_code", ) - return [d.new_item_code for d in bundled_items] - def get_packed_items(sales_order_list): packed_items = frappe.get_all( diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index acc281d60c80af68eab402137c06635653de7664..4c55c55bec9d51bef2a3f578a010d6f0003a993c 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -802,8 +802,8 @@ class DeliveryNote(SellingController): items_list = [item.item_code for item in self.items] return frappe.db.get_all( "Product Bundle", - filters={"new_item_code": ["in", items_list], "disabled": 0}, - pluck="name", + filters={"new_item_code": ["in", set(items_list)], "disabled": 0}, + pluck="new_item_code", ) diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index eb73695236d36462ac889d6d22dc68313c57e9f9..e584cdb80386cd41097ad8db51ea144f2b0c5146 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -96,6 +96,7 @@ "packed_qty", "column_break_fguf", "received_qty", + "product_bundle_name", "accounting_details_section", "expense_account", "column_break_71", @@ -939,12 +940,20 @@ { "fieldname": "column_break_fguf", "fieldtype": "Column Break" + }, + { + "fieldname": "product_bundle_name", + "fieldtype": "Link", + "hidden": 1, + "label": "Product Bundle", + "options": "Product Bundle", + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2025-02-05 14:27:32.322181", + "modified": "2025-02-14 14:47:43.804540", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py index d086e801ba3f6917113220235a1b61b9e90f6ff5..be454a3b53bac88efbea05c47f73146c0ba95122 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py @@ -65,6 +65,7 @@ class DeliveryNoteItem(Document): pick_list_item: DF.Data | None price_list_rate: DF.Currency pricing_rules: DF.SmallText | None + product_bundle_name: DF.Link | None project: DF.Link | None purchase_order: DF.Link | None purchase_order_item: DF.Data | None diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index f1c89902efa8132abc832999b3443d5e1b0dbe60..2646e8125ef682da5157fc3252e662133209aed9 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -655,21 +655,18 @@ class Item(Document): def validate_duplicate_product_bundles_before_merge(self, old_name, new_name): "Block merge if both old and new items have product bundles." - old_bundle = frappe.get_value( - "Product Bundle", filters={"new_item_code": old_name, "disabled": 0} - ) - new_bundle = frappe.get_value( - "Product Bundle", filters={"new_item_code": new_name, "disabled": 0} - ) - - if old_bundle and new_bundle: - bundle_link = get_link_to_form("Product Bundle", old_bundle) - old_name, new_name = frappe.bold(old_name), frappe.bold(new_name) - - msg = _("Please delete Product Bundle {0}, before merging {1} into {2}").format( - bundle_link, old_name, new_name - ) - frappe.throw(msg, title=_("Cannot Merge"), exc=DataValidationError) + pass + # old_bundle = frappe.get_value("Product Bundle", filters={"new_item_code": old_name, "disabled": 0}) + # new_bundle = frappe.get_value("Product Bundle", filters={"new_item_code": new_name, "disabled": 0}) + + # if old_bundle and new_bundle: + # bundle_link = get_link_to_form("Product Bundle", old_bundle) + # old_name, new_name = frappe.bold(old_name), frappe.bold(new_name) + + # msg = _("Please delete Product Bundle {0}, before merging {1} into {2}").format( + # bundle_link, old_name, new_name + # ) + # frappe.throw(msg, title=_("Cannot Merge"), exc=DataValidationError) def set_last_purchase_rate(self, new_name): last_purchase_rate = get_last_purchase_details(new_name).get("base_net_rate", 0) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index a0b0e1cf39bbe44306ee21599e7f943cc6b4430f..02ea47d55e12b1a1d59c0ace264c64478bef32cf 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -511,8 +511,8 @@ class TestItem(FrappeTestCase): bundle1 = make_product_bundle("Test Item Bundle Item 1", bundle_items, qty=2) make_product_bundle("Test Item Bundle Item 2", bundle_items, qty=2) - with self.assertRaises(DataValidationError): - frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True) + # with self.assertRaises(DataValidationError): + # frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True) bundle1.delete() frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index f601a2b055e72f8c2f92a35b5cdaf5405968d4eb..e55bf01a47b7578651e5569ff06077c34504c35d 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -5,6 +5,7 @@ import json +from typing import TYPE_CHECKING import frappe from frappe.model.document import Document @@ -12,6 +13,9 @@ from frappe.utils import flt from erpnext.stock.get_item_details import get_item_details, get_price_list_rate +if TYPE_CHECKING: + from erpnext.selling.doctype.product_bundle.product_bundle import ProductBundle + class PackedItem(Document): # begin: auto-generated types @@ -67,11 +71,14 @@ def make_packing_list(doc): reset = reset_packing_list(doc) for item_row in doc.get("items"): - if is_product_bundle(item_row.item_code): - editable_bundle_item_rates = frappe.db.get_value( - "Product Bundle", {"new_item_code": item_row.item_code}, "editable_bundle_item_rates" - ) - for bundle_item in get_product_bundle_items(item_row.item_code): + product_bundle_name = item_row.get("product_bundle_name") + product_bundle_name = get_product_bundle_name( + item_row.item_code, product_bundle_name=product_bundle_name + ) + if product_bundle_name: + bundle: "ProductBundle" = frappe.get_cached_doc("Product Bundle", product_bundle_name) # type: ignore + editable_bundle_item_rates = bundle.editable_bundle_item_rates + for bundle_item in bundle.items: pi_row = add_packed_item_row( doc=doc, packing_item=bundle_item, @@ -93,8 +100,29 @@ def make_packing_list(doc): doc.calculate_taxes_and_totals() -def is_product_bundle(item_code: str) -> bool: - return bool(frappe.db.exists("Product Bundle", {"new_item_code": item_code, "disabled": 0})) +def get_product_bundle_name(item_code="", *, product_bundle_name="") -> str: + if item_code and product_bundle_name: + if name := frappe.db.exists( + "Product Bundle", {"name": product_bundle_name, "new_item_code": item_code, "disabled": 0} + ): + return name # type: ignore + elif item_code: + if name := frappe.db.exists("Product Bundle", {"new_item_code": item_code, "disabled": 0}): + return name # type: ignore + return "" + + +def get_product_bundle(item_code="", *, product_bundle_name="") -> "ProductBundle | None": + try: + if item_code and product_bundle_name: + return frappe.get_last_doc( + "Product Bundle", {"name": product_bundle_name, "new_item_code": item_code, "disabled": 0} + ) # type: ignore + elif item_code: + return frappe.get_last_doc("Product Bundle", {"new_item_code": item_code, "disabled": 0}) # type: ignore + except frappe.exceptions.DoesNotExistError: + frappe.clear_last_message() + return None def get_indexed_packed_items_table(doc): @@ -104,10 +132,12 @@ def get_indexed_packed_items_table(doc): Use: to quickly retrieve/check if row existed in table instead of looping n times """ - indexed_table = {} + from collections import defaultdict + + indexed_table = defaultdict(list) for packed_item in doc.get("packed_items"): key = (packed_item.parent_item, packed_item.item_code, packed_item.parent_detail_docname) - indexed_table[key] = packed_item + indexed_table[key].append(packed_item) return indexed_table @@ -122,8 +152,13 @@ def reset_packing_list(doc): # 1. items were deleted # 2. if bundle item replaced by another item (same no. of items but different items) # we maintain list to track recurring item rows as well - items_before_save = [(item.name, item.item_code) for item in doc_before_save.get("items")] - items_after_save = [(item.name, item.item_code) for item in doc.get("items")] + items_before_save = [ + (item.name, item.item_code, item.product_bundle_name or "") + for item in doc_before_save.get("items") + ] + items_after_save = [ + (item.name, item.item_code, item.product_bundle_name or "") for item in doc.get("items") + ] reset_table = items_before_save != items_after_save else: # reset: if via Update Items OR @@ -136,7 +171,10 @@ def reset_packing_list(doc): return reset_table -def get_product_bundle_items(item_code): +def get_product_bundle_items(item_code="", *, product_bundle_name=""): + if not product_bundle_name: + product_bundle_name = get_product_bundle_name(item_code) + product_bundle = frappe.qb.DocType("Product Bundle") product_bundle_item = frappe.qb.DocType("Product Bundle Item") @@ -152,12 +190,14 @@ def get_product_bundle_items(item_code): product_bundle.editable_bundle_item_rates, ) .where((product_bundle.new_item_code == item_code) & (product_bundle.disabled == 0)) + .where(product_bundle.name == product_bundle_name) .orderby(product_bundle_item.idx) ) + return query.run(as_dict=True) -def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table, reset): +def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table: dict[tuple, list], reset): """Add and return packed item row. doc: Transaction document packing_item (dict): Packed Item details @@ -170,7 +210,7 @@ def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table, re # check if row already exists in packed items table key = (main_item_row.item_code, packing_item.item_code, main_item_row.name) if packed_items_table.get(key): - pi_row, exists = packed_items_table.get(key), True + pi_row, exists = packed_items_table[key].pop(), True if not exists: pi_row = doc.append("packed_items", {}) @@ -325,7 +365,9 @@ def on_doctype_update(): def get_items_from_product_bundle(row): row, items = json.loads(row), [] - bundled_items = get_product_bundle_items(row["item_code"]) + bundled_items = get_product_bundle_items( + row["item_code"], product_bundle_name=row.get("product_bundle_name") + ) for item in bundled_items: row.update({"item_code": item.item_code, "qty": flt(row["quantity"]) * flt(item.qty)}) items.append(get_item_details(row)) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index cdec815cba989d563660505bb4fbd11f52ab6966..8d84be39cecab6f0759269c3a6a8fc2549f3a0a9 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -19,6 +19,7 @@ from frappe.utils.nestedset import get_descendants_of from erpnext.selling.doctype.sales_order.sales_order import ( make_delivery_note as create_delivery_note_from_sales_order, ) +from erpnext.stock.doctype.packed_item.packed_item import get_product_bundle, get_product_bundle_name from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( get_auto_batch_nos, get_picked_serial_nos, @@ -560,9 +561,7 @@ class PickList(Document): frappe.throw("Row #{0}: Item Code is Mandatory".format(item.idx)) if not cint( frappe.get_cached_value("Item", item.item_code, "is_stock_item") - ) and not frappe.db.exists( - "Product Bundle", {"new_item_code": item.item_code, "disabled": 0} - ): + ) and not get_product_bundle_name(item.item_code): continue item_code = item.item_code reference = item.sales_order_item or item.material_request_item @@ -621,8 +620,8 @@ class PickList(Document): product_bundles = self._get_product_bundles() product_bundle_qty_map = self._get_product_bundle_qty_map(product_bundles.values()) - for so_row, item_code in product_bundles.items(): - picked_qty = self._compute_picked_qty_for_bundle(so_row, product_bundle_qty_map[item_code]) + for so_row, bundle_tup in product_bundles.items(): + picked_qty = self._compute_picked_qty_for_bundle(so_row, product_bundle_qty_map[bundle_tup]) item_table = "Sales Order Item" already_picked = frappe.db.get_value(item_table, so_row, "picked_qty", for_update=True) frappe.db.set_value( @@ -738,8 +737,8 @@ class PickList(Document): return query.run(as_dict=True) - def _get_product_bundles(self) -> dict[str, str]: - # Dict[so_item_row: item_code] + def _get_product_bundles(self) -> dict[str, tuple[str, str | None]]: + # Dict[so_item_row: (item_code, product_bundle_name)] product_bundles = {} for item in self.locations: if not item.product_bundle_item: @@ -747,21 +746,23 @@ class PickList(Document): product_bundles[item.product_bundle_item] = frappe.db.get_value( "Sales Order Item", item.product_bundle_item, - "item_code", + ["item_code", "product_bundle_name"], ) return product_bundles - def _get_product_bundle_qty_map(self, bundles: List[str]) -> Dict[str, Dict[str, float]]: + def _get_product_bundle_qty_map( + self, bundles: list[tuple[str, str | None]] + ) -> dict[tuple[str, str | None], dict[str, float]]: # bundle_item_code: Dict[component, qty] product_bundle_qty_map = {} - for bundle_item_code in bundles: - bundle = frappe.get_last_doc( - "Product Bundle", {"new_item_code": bundle_item_code, "disabled": 0} - ) - product_bundle_qty_map[bundle_item_code] = {item.item_code: item.qty for item in bundle.items} + for tup in bundles: + item_code, product_bundle_name = tup + bundle = get_product_bundle(item_code, product_bundle_name=product_bundle_name) + # TODO: Rows with same item code are not summed correctly + product_bundle_qty_map[tup] = {item.item_code: item.qty for item in bundle.items} return product_bundle_qty_map - def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items) -> int: + def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items: dict[str, float]) -> int: """Compute how many full bundles can be created from picked items.""" precision = frappe.get_precision("Stock Ledger Entry", "qty_after_transaction") @@ -1292,11 +1293,11 @@ def add_product_bundles_to_delivery_note( product_bundles = pick_list._get_product_bundles() product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values()) - for so_row, item_code in product_bundles.items(): + for so_row, bundle_tup in product_bundles.items(): sales_order_item = frappe.get_doc("Sales Order Item", so_row) dn_bundle_item = map_child_doc(sales_order_item, delivery_note, item_mapper) dn_bundle_item.qty = pick_list._compute_picked_qty_for_bundle( - so_row, product_bundle_qty_map[item_code] + so_row, product_bundle_qty_map[bundle_tup] ) update_delivery_note_item(sales_order_item, dn_bundle_item, delivery_note) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index cf9c61c4391d6f8b9710b2b8c1bdd4ddc868b2d3..6db5b16d955bd5a55a259bfe0fae7c1cc153251c 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -160,11 +160,13 @@ def remove_standard_fields(details): def set_valuation_rate(out, args): - if frappe.db.exists("Product Bundle", {"name": args.item_code, "disabled": 0}, cache=True): + from erpnext.stock.doctype.packed_item.packed_item import get_product_bundle, get_product_bundle_name + + if get_product_bundle_name(args.item_code, product_bundle_name=args.product_bundle_name): valuation_rate = 0.0 - bundled_items = frappe.get_doc("Product Bundle", args.item_code) + product_bundle = get_product_bundle(args.item_code, product_bundle_name=args.product_bundle_name) - for bundle_item in bundled_items.items: + for bundle_item in product_bundle.items: valuation_rate += flt( get_valuation_rate(bundle_item.item_code, args.company, out.get("warehouse")).get( "valuation_rate" diff --git a/erpnext/stock/report/product_bundle_balance/product_bundle_balance.js b/erpnext/stock/report/product_bundle_balance/product_bundle_balance.js index ca92b0c45f62e976df6c90237a84ae3abe70ddd3..545bde02a473bc15244873484193df2f462b9503 100644 --- a/erpnext/stock/report/product_bundle_balance/product_bundle_balance.js +++ b/erpnext/stock/report/product_bundle_balance/product_bundle_balance.js @@ -53,9 +53,17 @@ frappe.query_reports["Product Bundle Balance"] = { "options": "Warehouse" }, ], - "initial_depth": 0, - "formatter": function(value, row, column, data, default_formatter) { - value = default_formatter(value, row, column, data); + initial_depth: 0, + formatter: function (value, row, column, data, default_formatter) { + if (data?.product_bundle_name && column?.fieldname === "item_code") { + value = frappe.format(data.product_bundle_name, { + fieldtype: "Link", + options: "Product Bundle", + }); + } else { + value = default_formatter(value, row, column, data); + } + if (!data.parent_item) { value = $(`${value}`); var $value = $(value).css("font-weight", "bold"); diff --git a/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py b/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py index 10f8650b525c5024790b2144f4dc0d9932023a9c..9251cac14f30c190e625ec9a5c4e2f034b079d52 100644 --- a/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py +++ b/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py @@ -16,14 +16,13 @@ def execute(filters=None): filters = frappe._dict() columns = get_columns() - item_details, pb_details, parent_items, child_items = get_items(filters) + bundle_details, bundle_contents, bundle_names, child_items = get_items(filters) stock_balance = get_stock_balance(filters, child_items) data = [] - for parent_item in parent_items: - parent_item_detail = item_details[parent_item] - - required_items = pb_details[parent_item] + for parent_bundle in bundle_names: + parent_item_detail = bundle_details[parent_bundle] + required_items = bundle_contents[parent_bundle] warehouse_company_map = {} for child_item in required_items: child_item_balance = stock_balance.get(child_item.item_code, frappe._dict()) @@ -34,7 +33,7 @@ def execute(filters=None): for warehouse, company in warehouse_company_map.items(): parent_row = { "indent": 0, - "item_code": parent_item, + "product_bundle_name": parent_bundle, "item_name": parent_item_detail.item_name, "item_group": parent_item_detail.item_group, "brand": parent_item_detail.brand, @@ -51,7 +50,7 @@ def execute(filters=None): ) child_row = { "indent": 1, - "parent_item": parent_item, + "parent_item": parent_bundle, "item_code": child_item_detail.item_code, "item_name": child_item_detail.item_name, "item_group": child_item_detail.item_group, @@ -122,8 +121,8 @@ def get_columns(): def get_items(filters): - pb_details = frappe._dict() - item_details = frappe._dict() + bundle_contents = frappe._dict() + bundle_details = frappe._dict() item = frappe.qb.DocType("Item") pb = frappe.qb.DocType("Product Bundle") @@ -133,6 +132,7 @@ def get_items(filters): .inner_join(pb) .on(pb.new_item_code == item.name) .select( + pb.name.as_("product_bundle_name"), item.name.as_("item_code"), item.item_name, pb.description, @@ -141,6 +141,7 @@ def get_items(filters): item.stock_uom, ) .where(IfNull(item.disabled, 0) == 0) + .where(IfNull(pb.disabled, 0) == 0) ) if item_code := filters.get("item_code"): @@ -154,13 +155,13 @@ def get_items(filters): parent_item_details = query.run(as_dict=True) - parent_items = [] + bundle_names = [] for d in parent_item_details: - parent_items.append(d.item_code) - item_details[d.item_code] = d + bundle_names.append(d.product_bundle_name) + bundle_details[d.product_bundle_name] = d child_item_details = [] - if parent_items: + if bundle_names: item = frappe.qb.DocType("Item") pb = frappe.qb.DocType("Product Bundle") pbi = frappe.qb.DocType("Product Bundle Item") @@ -172,7 +173,7 @@ def get_items(filters): .inner_join(item) .on(item.name == pbi.item_code) .select( - pb.new_item_code.as_("parent_item"), + pb.name.as_("product_bundle_name"), pbi.item_code, item.item_name, pbi.description, @@ -182,18 +183,16 @@ def get_items(filters): pbi.uom, pbi.qty, ) - .where(pb.new_item_code.isin(parent_items)) + .where(pb.name.isin(bundle_names)) ).run(as_dict=1) child_items = set() for d in child_item_details: - if d.item_code != d.parent_item: - pb_details.setdefault(d.parent_item, []).append(d) - child_items.add(d.item_code) - item_details[d.item_code] = d + bundle_contents.setdefault(d.product_bundle_name, []).append(d) + child_items.add(d.item_code) child_items = list(child_items) - return item_details, pb_details, parent_items, child_items + return bundle_details, bundle_contents, bundle_names, child_items def get_stock_balance(filters, items):