From 35ee0694bfd36208cd53cbd4a1c920c4448a6919 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Thu, 13 Feb 2025 17:23:00 +0100 Subject: [PATCH 01/23] refactor(bundle): Handle in Packed Item --- .../stock/doctype/packed_item/packed_item.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index f601a2b055..083e85dbd6 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -67,11 +67,12 @@ 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): + product_bundle_name = item_row.get("product_bundle_name") + if is_product_bundle(item_row.item_code, product_bundle_name=product_bundle_name): 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): + ) # TODO: use correct bundle + for bundle_item in get_product_bundle_items(item_row.item_code, product_bundle_name=product_bundle_name): pi_row = add_packed_item_row( doc=doc, packing_item=bundle_item, @@ -93,7 +94,9 @@ def make_packing_list(doc): doc.calculate_taxes_and_totals() -def is_product_bundle(item_code: str) -> bool: +def is_product_bundle(item_code="", *, product_bundle_name="") -> bool: + if product_bundle_name: + return bool(frappe.db.exists("Product Bundle", {"name": product_bundle_name, "disabled": 0})) return bool(frappe.db.exists("Product Bundle", {"new_item_code": item_code, "disabled": 0})) @@ -136,7 +139,7 @@ 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=""): product_bundle = frappe.qb.DocType("Product Bundle") product_bundle_item = frappe.qb.DocType("Product Bundle Item") @@ -151,9 +154,14 @@ def get_product_bundle_items(item_code): product_bundle_item.description, product_bundle.editable_bundle_item_rates, ) - .where((product_bundle.new_item_code == item_code) & (product_bundle.disabled == 0)) .orderby(product_bundle_item.idx) ) + + if product_bundle_name: + query = query.where((product_bundle.name == product_bundle_name) & (product_bundle.disabled == 0)) + else: + query = query.where((product_bundle.new_item_code == item_code) & (product_bundle.disabled == 0)) + return query.run(as_dict=True) @@ -325,7 +333,7 @@ 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)) -- GitLab From ad38c59c74d97dcf263ddc08371061dce04301d1 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Thu, 13 Feb 2025 16:55:08 +0100 Subject: [PATCH 02/23] refactor(bundle): Handle in POS --- .../doctype/pos_invoice/pos_invoice.py | 20 ++++++++++++------- .../page/point_of_sale/point_of_sale.py | 7 ++++++- .../page/point_of_sale/pos_controller.js | 12 +++++++---- .../page/point_of_sale/pos_item_details.js | 2 +- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 15b2c59e61..5a5d272d86 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 is_product_bundle 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,19 @@ 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 is_product_bundle(item_code, product_bundle_name=product_bundle_name): + return get_bundle_availability(product_bundle_name, warehouse), 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=""): + if product_bundle_name: + product_bundle = frappe.get_doc("Product Bundle", product_bundle_name) + else: + product_bundle = frappe.get_doc("Product Bundle", {"new_item_code": bundle_item_code}) bundle_bin_qty = 1000000 for item in product_bundle.items: @@ -777,7 +783,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/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py index fcc1933294..89c9d5ff28 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 7b9f10ceb5..5fa1050cd0 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 27d17c69d1..c6ed813d9f 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); }) -- GitLab From ff54a1c81d1b1e65a66a5838ab9a501c3ecc21ce Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Thu, 13 Feb 2025 17:23:43 +0100 Subject: [PATCH 03/23] refactor(bundle): Handle in selling/Sales Order --- erpnext/controllers/selling_controller.py | 4 +++- erpnext/selling/doctype/sales_order/sales_order.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index f7e6356ee0..7d0258e8b6 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -320,7 +320,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 @@ -376,6 +376,7 @@ class SellingController(StockController): return il def has_product_bundle(self, item_code): + print("deprecated") product_bundle_items = getattr(self, "_product_bundle_items", None) if product_bundle_items is None: self._product_bundle_items = product_bundle_items = {} @@ -386,6 +387,7 @@ class SellingController(StockController): return product_bundle_items[item_code] def _fetch_product_bundle_items(self, item_code): + print("deprecated") product_bundle_items = self._product_bundle_items items_to_fetch = {row.item_code for row in self.items if row.item_code not in product_bundle_items} # fetch for requisite item_code even if it is not in items diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 5089b73ee3..aec226181c 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -543,7 +543,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) @@ -619,7 +619,7 @@ class SalesOrder(SellingController): 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): + ) 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) @@ -1676,7 +1676,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 +1724,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 is_product_bundle + + return is_product_bundle(item_code, product_bundle_name=product_bundle_name) @frappe.whitelist() @@ -1869,7 +1871,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 is_product_bundle(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. -- GitLab From b75f16532844b9ed5930099ee0723e8cbf0d7537 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 14 Feb 2025 13:35:50 +0100 Subject: [PATCH 04/23] refactor(bundle): Handle in Pick List --- erpnext/controllers/selling_controller.py | 2 -- .../doctype/product_bundle/product_bundle.py | 10 +++--- .../doctype/sales_order/sales_order.py | 25 ++++++------- .../pending_so_items_for_purchase_request.py | 12 +++---- .../doctype/delivery_note/delivery_note.py | 4 +-- erpnext/stock/doctype/item/item.py | 27 +++++++------- .../stock/doctype/packed_item/packed_item.py | 20 +++++++++-- erpnext/stock/doctype/pick_list/pick_list.py | 35 ++++++++++--------- erpnext/stock/get_item_details.py | 8 +++-- 9 files changed, 79 insertions(+), 64 deletions(-) diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 7d0258e8b6..4773d0bafe 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -376,7 +376,6 @@ class SellingController(StockController): return il def has_product_bundle(self, item_code): - print("deprecated") product_bundle_items = getattr(self, "_product_bundle_items", None) if product_bundle_items is None: self._product_bundle_items = product_bundle_items = {} @@ -387,7 +386,6 @@ class SellingController(StockController): return product_bundle_items[item_code] def _fetch_product_bundle_items(self, item_code): - print("deprecated") product_bundle_items = self._product_bundle_items items_to_fetch = {row.item_code for row in self.items if row.item_code not in product_bundle_items} # fetch for requisite item_code even if it is not in items diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.py b/erpnext/selling/doctype/product_bundle/product_bundle.py index eba763fa96..9f2d78da79 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 is_product_bundle + class ProductBundle(Document): # begin: auto-generated types @@ -82,7 +84,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 is_product_bundle(item.item_code): frappe.throw( _( "Row #{0}: Child Item should not be a Product Bundle. Please remove Item {1} and Save" @@ -93,7 +95,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 +117,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/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index aec226181c..616396d546 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -617,9 +617,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 so_item.product_bundle_name 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 +982,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, }, @@ -1941,12 +1943,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/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 680e379651..b4182dcb3e 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 acc281d60c..4c55c55bec 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/item/item.py b/erpnext/stock/doctype/item/item.py index f1c89902ef..2646e8125e 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/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 083e85dbd6..a6eef43c7e 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 @@ -72,7 +76,9 @@ def make_packing_list(doc): editable_bundle_item_rates = frappe.db.get_value( "Product Bundle", {"new_item_code": item_row.item_code}, "editable_bundle_item_rates" ) # TODO: use correct bundle - for bundle_item in get_product_bundle_items(item_row.item_code, product_bundle_name=product_bundle_name): + for bundle_item in get_product_bundle_items( + item_row.item_code, product_bundle_name=product_bundle_name + ): pi_row = add_packed_item_row( doc=doc, packing_item=bundle_item, @@ -95,11 +101,19 @@ def make_packing_list(doc): def is_product_bundle(item_code="", *, product_bundle_name="") -> bool: + if not item_code and not product_bundle_name: + return False if product_bundle_name: return bool(frappe.db.exists("Product Bundle", {"name": product_bundle_name, "disabled": 0})) return bool(frappe.db.exists("Product Bundle", {"new_item_code": item_code, "disabled": 0})) +def get_product_bundle(item_code="", *, product_bundle_name="") -> "ProductBundle": + if product_bundle_name: + return frappe.get_doc("Product Bundle", product_bundle_name) # type: ignore + return frappe.get_last_doc("Product Bundle", {"new_item_code": item_code, "disabled": 0}) # type: ignore + + def get_indexed_packed_items_table(doc): """ Create dict from stale packed items table like: @@ -333,7 +347,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"], product_bundle_name=row.get("product_bundle_name")) + 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 cdec815cba..cfa65f6f1f 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, is_product_bundle 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 is_product_bundle(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 cf9c61c439..88fc5f07d7 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, is_product_bundle + + if is_product_bundle(ctx.item_code, product_bundle_name=ctx.product_bundle_name): valuation_rate = 0.0 - bundled_items = frappe.get_doc("Product Bundle", args.item_code) + product_bundle = get_product_bundle(ctx.item_code, product_bundle_name=ctx.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" -- GitLab From 9532996fed6cca9249a8495bb4d765a98aa72be4 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 14 Feb 2025 14:04:20 +0100 Subject: [PATCH 05/23] refactor(bundle): Add missing fields --- .../sales_invoice_item.json | 9 +++++++++ .../sales_invoice_item/sales_invoice_item.py | 1 + .../product_bundle/product_bundle.json | 6 ++++-- .../doctype/product_bundle/product_bundle.py | 2 +- .../product_bundle_item.json | 2 +- .../product_bundle_item.py | 18 ++++++++++++++++++ .../quotation_item/quotation_item.json | 19 ++++++++++++++----- .../doctype/quotation_item/quotation_item.py | 1 + .../sales_order_item/sales_order_item.json | 17 +++++++++++++---- .../sales_order_item/sales_order_item.py | 1 + .../delivery_note_item.json | 11 ++++++++++- .../delivery_note_item/delivery_note_item.py | 1 + 12 files changed, 74 insertions(+), 14 deletions(-) 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 aaa832f00c..41e61101ba 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 7c68cbec29..d0b1d2165c 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/selling/doctype/product_bundle/product_bundle.json b/erpnext/selling/doctype/product_bundle/product_bundle.json index 811ebb7d00..63d1977b1a 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", @@ -84,7 +85,7 @@ "icon": "fa fa-sitemap", "idx": 1, "links": [], - "modified": "2025-01-21 11:50:07.516599", + "modified": "2025-02-14 13:40:37.532753", "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 9f2d78da79..55654faf3a 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/product_bundle.py @@ -30,7 +30,7 @@ class ProductBundle(Document): # end: auto-generated types def autoname(self): - self.name = self.new_item_code + self.name = f"{self.new_item_code}-{frappe.generate_hash(length=8)}" def validate(self): self.validate_main_item() 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 a810be95e4..15b4e89df2 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 7b3d974696..830b07ec52 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 f122f75c57..de4bbaa7a0 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 a10d08bc88..27949a4991 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_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index d362bffba1..f8af58089f 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 c785adf08d..b289c1b68d 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/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index eb73695236..e584cdb803 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 d086e801ba..be454a3b53 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 -- GitLab From 9f51599761bac1d994b93eb89da8669b40e06d35 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 14 Feb 2025 14:05:14 +0100 Subject: [PATCH 06/23] refactor(bundle): Set product bundle of item row on save --- erpnext/controllers/selling_controller.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 4773d0bafe..0e41c59b77 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 @@ -829,6 +830,10 @@ class SellingController(StockController): validate_item_type(self, "is_sales_item", _("sales")) + for item in self.items: + if not item.product_bundle_name and self.has_product_bundle(item.item_code): + item.product_bundle_name = get_product_bundle(item.item_code).name + def validate_recurring_items(self): if self.recurrence_period: for item in self.items: -- GitLab From 401756caae186733c917c9f08f9f144a8fba808a Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 14 Feb 2025 14:57:38 +0100 Subject: [PATCH 07/23] refactor(bundle): Add random suffix to autoname --- erpnext/selling/doctype/product_bundle/product_bundle.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.py b/erpnext/selling/doctype/product_bundle/product_bundle.py index 55654faf3a..1f34d6bd01 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/product_bundle.py @@ -30,7 +30,9 @@ class ProductBundle(Document): # end: auto-generated types def autoname(self): - self.name = f"{self.new_item_code}-{frappe.generate_hash(length=8)}" + 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() -- GitLab From 945bd4973a9fd82f7d5de7617945d912ea0c5f38 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 14 Feb 2025 15:15:06 +0100 Subject: [PATCH 08/23] fix: Product Bundle Balance report for multi-version bundles --- .../product_bundle_balance.js | 14 +++++-- .../product_bundle_balance.py | 39 +++++++++---------- 2 files changed, 30 insertions(+), 23 deletions(-) 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 ca92b0c45f..545bde02a4 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 10f8650b52..9251cac14f 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): -- GitLab From a88d5589175158e1924eedb0c9cd6135dcba1998 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Mon, 17 Feb 2025 11:53:22 +0100 Subject: [PATCH 09/23] fix(bundle): Use product_bundle_name if available --- erpnext/accounts/doctype/pos_invoice/pos_invoice.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 5a5d272d86..8e413cf8f9 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -17,7 +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 is_product_bundle +from erpnext.stock.doctype.packed_item.packed_item import get_product_bundle, is_product_bundle from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -758,7 +758,9 @@ def get_stock_availability(item_code, warehouse, product_bundle_name=""): else: is_stock_item = True if is_product_bundle(item_code, product_bundle_name=product_bundle_name): - return get_bundle_availability(product_bundle_name, warehouse), is_stock_item + 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 @@ -766,10 +768,7 @@ def get_stock_availability(item_code, warehouse, product_bundle_name=""): def get_bundle_availability(bundle_item_code, warehouse, product_bundle_name=""): - if product_bundle_name: - product_bundle = frappe.get_doc("Product Bundle", product_bundle_name) - else: - product_bundle = frappe.get_doc("Product Bundle", {"new_item_code": bundle_item_code}) + product_bundle = get_product_bundle(bundle_item_code, product_bundle_name=product_bundle_name) bundle_bin_qty = 1000000 for item in product_bundle.items: -- GitLab From 28c92dea60473b9afd7e1a3c6edac3b88984f4fd Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Mon, 17 Feb 2025 11:53:54 +0100 Subject: [PATCH 10/23] fix(bundle): Fix product_bundle_name value when changing item_code --- erpnext/controllers/selling_controller.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 0e41c59b77..40e1f2e596 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -831,8 +831,15 @@ class SellingController(StockController): validate_item_type(self, "is_sales_item", _("sales")) for item in self.items: - if not item.product_bundle_name and self.has_product_bundle(item.item_code): - item.product_bundle_name = get_product_bundle(item.item_code).name + 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 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: -- GitLab From b37f26bf36a6a24f2b8cb4c8e552016c15e7f4c4 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Mon, 17 Feb 2025 13:25:49 +0100 Subject: [PATCH 11/23] test: Allow merging Items even if they have product bundles --- erpnext/stock/doctype/item/test_item.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index a0b0e1cf39..02ea47d55e 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) -- GitLab From bee884d7067927936b26e060edea0905a6ea96a4 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Thu, 20 Feb 2025 19:36:01 +0100 Subject: [PATCH 12/23] fix: Don't return disabled bundles --- erpnext/controllers/selling_controller.py | 2 +- erpnext/stock/doctype/packed_item/packed_item.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 40e1f2e596..9db132b8e7 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -834,7 +834,7 @@ class SellingController(StockController): 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 bundle.new_item_code != item.item_code: + 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 diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index a6eef43c7e..de16fc950e 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -108,10 +108,14 @@ def is_product_bundle(item_code="", *, product_bundle_name="") -> bool: return bool(frappe.db.exists("Product Bundle", {"new_item_code": item_code, "disabled": 0})) -def get_product_bundle(item_code="", *, product_bundle_name="") -> "ProductBundle": - if product_bundle_name: - return frappe.get_doc("Product Bundle", product_bundle_name) # type: ignore - return frappe.get_last_doc("Product Bundle", {"new_item_code": item_code, "disabled": 0}) # type: ignore +def get_product_bundle(item_code="", *, product_bundle_name="") -> "ProductBundle | None": + try: + if product_bundle_name: + return frappe.get_doc("Product Bundle", {"name": product_bundle_name, "disabled": 0}) # type: ignore + 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): -- GitLab From 9b00185e68d3495472e50e2c945f1f31f6afb7ec Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 28 Feb 2025 13:17:14 +0100 Subject: [PATCH 13/23] refactor: Add as_name param to is_product_bundle --- erpnext/stock/doctype/packed_item/packed_item.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index de16fc950e..f5d4b4123f 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -100,12 +100,18 @@ def make_packing_list(doc): doc.calculate_taxes_and_totals() -def is_product_bundle(item_code="", *, product_bundle_name="") -> bool: +def is_product_bundle(item_code="", *, product_bundle_name="", as_name=False) -> bool | str: if not item_code and not product_bundle_name: return False + if product_bundle_name: - return bool(frappe.db.exists("Product Bundle", {"name": product_bundle_name, "disabled": 0})) - return bool(frappe.db.exists("Product Bundle", {"new_item_code": item_code, "disabled": 0})) + name = frappe.db.exists("Product Bundle", {"name": product_bundle_name, "disabled": 0}) + else: + name = frappe.db.exists("Product Bundle", {"new_item_code": item_code, "disabled": 0}) + + if as_name: + return name # type: ignore + return bool(name) def get_product_bundle(item_code="", *, product_bundle_name="") -> "ProductBundle | None": -- GitLab From 944f9f4456fdc615405e43dc08928fb20bfddb75 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 28 Feb 2025 13:16:51 +0100 Subject: [PATCH 14/23] fix: Return only latest bundle in get_product_bundle_items if no name given --- erpnext/stock/doctype/packed_item/packed_item.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index f5d4b4123f..59d13ad077 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -164,6 +164,9 @@ def reset_packing_list(doc): def get_product_bundle_items(item_code="", *, product_bundle_name=""): + if not product_bundle_name: + product_bundle_name = is_product_bundle(item_code, as_name=True) + product_bundle = frappe.qb.DocType("Product Bundle") product_bundle_item = frappe.qb.DocType("Product Bundle Item") @@ -178,14 +181,11 @@ def get_product_bundle_items(item_code="", *, product_bundle_name=""): product_bundle_item.description, 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) ) - if product_bundle_name: - query = query.where((product_bundle.name == product_bundle_name) & (product_bundle.disabled == 0)) - else: - query = query.where((product_bundle.new_item_code == item_code) & (product_bundle.disabled == 0)) - return query.run(as_dict=True) -- GitLab From 180027cf9e6807ba9f2442ac6b9e0402609ac645 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 28 Feb 2025 13:18:43 +0100 Subject: [PATCH 15/23] fix: Set product bundle in update_child_qty_rate --- erpnext/controllers/accounts_controller.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 894ad38a59..7bd75b4bef 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 is_product_bundle, make_packing_list from erpnext.stock.get_item_details import ( _get_item_tax_template, get_conversion_factor, @@ -3581,6 +3581,14 @@ 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 := is_product_bundle( + child_item.item_code, + product_bundle_name=child_item.product_bundle_name, + as_name=True, + ): + 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 -- GitLab From e093ecb99d6a0b3cafa0ea692f78ace4d2b48325 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 28 Feb 2025 13:20:09 +0100 Subject: [PATCH 16/23] fix: Validate product bundle in update_child_qty_rate --- erpnext/controllers/accounts_controller.py | 11 +++++++++++ erpnext/controllers/selling_controller.py | 2 ++ 2 files changed, 13 insertions(+) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 7bd75b4bef..251a46753d 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -3711,6 +3711,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() @@ -3740,6 +3741,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 9db132b8e7..750445b705 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -35,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() @@ -830,6 +831,7 @@ 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): -- GitLab From 980f1f046013aed08c221dd9e9a9435d77253aa1 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 28 Feb 2025 13:20:39 +0100 Subject: [PATCH 17/23] fix: Detect changes to product_bundle_name in make_packing_list --- erpnext/stock/doctype/packed_item/packed_item.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 59d13ad077..c59c451e7a 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -149,8 +149,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 -- GitLab From 06ad9c271d6092928672fd6209b702c768f3b1e8 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 28 Feb 2025 13:36:01 +0100 Subject: [PATCH 18/23] refactor: Rename to get_product_bundle_name --- .../doctype/pos_invoice/pos_invoice.py | 4 +-- erpnext/controllers/accounts_controller.py | 5 ++- .../doctype/product_bundle/product_bundle.py | 4 +-- .../doctype/sales_order/sales_order.py | 8 ++--- .../stock/doctype/packed_item/packed_item.py | 35 ++++++++++--------- erpnext/stock/doctype/pick_list/pick_list.py | 4 +-- erpnext/stock/get_item_details.py | 6 ++-- 7 files changed, 33 insertions(+), 33 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 8e413cf8f9..1b28724102 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -17,7 +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, is_product_bundle +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 @@ -757,7 +757,7 @@ def get_stock_availability(item_code, warehouse, product_bundle_name=""): return bin_qty - pos_sales_qty, is_stock_item else: is_stock_item = True - if is_product_bundle(item_code, product_bundle_name=product_bundle_name): + 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 diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 251a46753d..e5353f955a 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 is_product_bundle, 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, @@ -3582,10 +3582,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil child_item.fg_item = d["fg_item"] if parent.doctype == "Sales Order": - if product_bundle_name := is_product_bundle( + if product_bundle_name := get_product_bundle_name( child_item.item_code, product_bundle_name=child_item.product_bundle_name, - as_name=True, ): child_item.product_bundle_name = product_bundle_name diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.py b/erpnext/selling/doctype/product_bundle/product_bundle.py index 1f34d6bd01..d8a6fee9fa 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/product_bundle.py @@ -8,7 +8,7 @@ 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 is_product_bundle +from erpnext.stock.doctype.packed_item.packed_item import get_product_bundle_name class ProductBundle(Document): @@ -86,7 +86,7 @@ class ProductBundle(Document): def validate_child_items(self): for item in self.items: - if is_product_bundle(item.item_code): + 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" diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 616396d546..a95802ea20 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1727,9 +1727,9 @@ def set_delivery_date(items, sales_order): def is_product_bundle(item_code="", *, product_bundle_name=""): - 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 - return is_product_bundle(item_code, product_bundle_name=product_bundle_name) + return get_product_bundle_name(item_code, product_bundle_name=product_bundle_name) @frappe.whitelist() @@ -1840,7 +1840,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) @@ -1873,7 +1873,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, product_bundle_name=item.product_bundle_name) + 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. diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index c59c451e7a..216c602b95 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -72,7 +72,7 @@ def make_packing_list(doc): for item_row in doc.get("items"): product_bundle_name = item_row.get("product_bundle_name") - if is_product_bundle(item_row.item_code, product_bundle_name=product_bundle_name): + if get_product_bundle_name(item_row.item_code, product_bundle_name=product_bundle_name): editable_bundle_item_rates = frappe.db.get_value( "Product Bundle", {"new_item_code": item_row.item_code}, "editable_bundle_item_rates" ) # TODO: use correct bundle @@ -100,25 +100,26 @@ def make_packing_list(doc): doc.calculate_taxes_and_totals() -def is_product_bundle(item_code="", *, product_bundle_name="", as_name=False) -> bool | str: - if not item_code and not product_bundle_name: - return False - - if product_bundle_name: - name = frappe.db.exists("Product Bundle", {"name": product_bundle_name, "disabled": 0}) - else: - name = frappe.db.exists("Product Bundle", {"new_item_code": item_code, "disabled": 0}) - - if as_name: - return name # type: ignore - return bool(name) +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 product_bundle_name: - return frappe.get_doc("Product Bundle", {"name": product_bundle_name, "disabled": 0}) # type: ignore - return frappe.get_last_doc("Product Bundle", {"new_item_code": item_code, "disabled": 0}) # type: ignore + 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 @@ -170,7 +171,7 @@ def reset_packing_list(doc): def get_product_bundle_items(item_code="", *, product_bundle_name=""): if not product_bundle_name: - product_bundle_name = is_product_bundle(item_code, as_name=True) + 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") diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index cfa65f6f1f..8d84be39ce 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -19,7 +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, is_product_bundle +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, @@ -561,7 +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 is_product_bundle(item.item_code): + ) 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 diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 88fc5f07d7..6db5b16d95 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -160,11 +160,11 @@ def remove_standard_fields(details): def set_valuation_rate(out, args): - from erpnext.stock.doctype.packed_item.packed_item import get_product_bundle, is_product_bundle + from erpnext.stock.doctype.packed_item.packed_item import get_product_bundle, get_product_bundle_name - if is_product_bundle(ctx.item_code, product_bundle_name=ctx.product_bundle_name): + if get_product_bundle_name(args.item_code, product_bundle_name=args.product_bundle_name): valuation_rate = 0.0 - product_bundle = get_product_bundle(ctx.item_code, product_bundle_name=ctx.product_bundle_name) + product_bundle = get_product_bundle(args.item_code, product_bundle_name=args.product_bundle_name) for bundle_item in product_bundle.items: valuation_rate += flt( -- GitLab From 8027567b621b121f797cb0b7daf680ca06a794b1 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 28 Feb 2025 15:30:06 +0100 Subject: [PATCH 19/23] fix: Fix product bundle rate not computed --- erpnext/stock/doctype/packed_item/packed_item.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 216c602b95..bd138cb56c 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -72,13 +72,13 @@ def make_packing_list(doc): for item_row in doc.get("items"): product_bundle_name = item_row.get("product_bundle_name") - if get_product_bundle_name(item_row.item_code, product_bundle_name=product_bundle_name): - editable_bundle_item_rates = frappe.db.get_value( - "Product Bundle", {"new_item_code": item_row.item_code}, "editable_bundle_item_rates" - ) # TODO: use correct bundle - for bundle_item in get_product_bundle_items( - item_row.item_code, product_bundle_name=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, -- GitLab From 85ff5d885eedec8a2b8ffe526c5dd9f3c86670dd Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 28 Feb 2025 15:30:28 +0100 Subject: [PATCH 20/23] fix: Allow repeated item rows in bundle --- erpnext/stock/doctype/packed_item/packed_item.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index bd138cb56c..e55bf01a47 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -132,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 @@ -195,7 +197,7 @@ def get_product_bundle_items(item_code="", *, product_bundle_name=""): 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 @@ -208,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", {}) -- GitLab From e8f1ea6d9a85676d7ab7ac51e92c6318dce5696d Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Thu, 6 Mar 2025 11:47:40 +0100 Subject: [PATCH 21/23] feat(bundle): Make description a Text Editor field --- erpnext/selling/doctype/product_bundle/product_bundle.json | 4 ++-- erpnext/selling/doctype/product_bundle/product_bundle.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.json b/erpnext/selling/doctype/product_bundle/product_bundle.json index 63d1977b1a..790e77cd50 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.json +++ b/erpnext/selling/doctype/product_bundle/product_bundle.json @@ -37,7 +37,7 @@ }, { "fieldname": "description", - "fieldtype": "Data", + "fieldtype": "Text Editor", "in_list_view": 1, "label": "Description" }, @@ -85,7 +85,7 @@ "icon": "fa fa-sitemap", "idx": 1, "links": [], - "modified": "2025-02-14 13:40:37.532753", + "modified": "2025-03-06 11:46:14.675103", "modified_by": "Administrator", "module": "Selling", "name": "Product Bundle", diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.py b/erpnext/selling/doctype/product_bundle/product_bundle.py index d8a6fee9fa..af7f6852b7 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/product_bundle.py @@ -22,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] -- GitLab From 77c930f68624bf58d6fafee7af81626b79a1fc90 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Thu, 6 Mar 2025 15:21:47 +0100 Subject: [PATCH 22/23] fix(SO): Fix get_ordered_qty estimate for bundle items when creating PO --- erpnext/selling/doctype/sales_order/sales_order.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 8a68010fb8..6903e2bbe0 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; -- GitLab From bc0c4e16efa0419322a882d1b3b4a4d2e0621e5b Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 14 Mar 2025 11:49:34 +0100 Subject: [PATCH 23/23] fix: Validate warehouse based on bundle name if exists --- erpnext/selling/doctype/sales_order/sales_order.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index a95802ea20..5f503669c7 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) -- GitLab