mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-14 09:23:09 +00:00
feat: make Product Bundle submittable and versioned (#55702)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date
|
||||
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
|
||||
from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle
|
||||
|
||||
|
||||
def get_invoiced_qty_map(delivery_note: str) -> dict:
|
||||
@@ -287,8 +288,7 @@ def make_packing_slip(source_name: str, target_doc: str | Document | None = None
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda item: (
|
||||
not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code, "disabled": 0})
|
||||
and flt(item.packed_qty) < flt(item.qty)
|
||||
not is_product_bundle(item.item_code) and flt(item.packed_qty) < flt(item.qty)
|
||||
),
|
||||
},
|
||||
"Packed Item": {
|
||||
|
||||
@@ -44,7 +44,7 @@ class PackingService:
|
||||
items_list = [item.item_code for item in self.doc.items]
|
||||
return frappe.db.get_all(
|
||||
"Product Bundle",
|
||||
filters={"new_item_code": ["in", items_list], "disabled": 0},
|
||||
filters={"new_item_code": ["in", items_list], "is_active": 1, "docstatus": 1},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
|
||||
@@ -712,8 +712,10 @@ 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})
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
old_bundle = get_active_product_bundle(old_name)
|
||||
new_bundle = get_active_product_bundle(new_name)
|
||||
|
||||
if old_bundle and new_bundle:
|
||||
bundle_link = get_link_to_form("Product Bundle", old_bundle)
|
||||
@@ -1146,7 +1148,7 @@ class Item(Document):
|
||||
|
||||
if doctype in ("Product Bundle", "BOM"):
|
||||
if doctype == "Product Bundle":
|
||||
filters = {"new_item_code": self.name}
|
||||
filters = {"new_item_code": self.name, "is_active": 1, "docstatus": 1}
|
||||
fieldname = "new_item_code as docname"
|
||||
else:
|
||||
filters = {"item": self.name, "docstatus": 1}
|
||||
|
||||
@@ -542,6 +542,7 @@ class TestItem(ERPNextTestSuite):
|
||||
with self.assertRaises(DataValidationError):
|
||||
frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True)
|
||||
|
||||
bundle1.cancel()
|
||||
bundle1.delete()
|
||||
frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True)
|
||||
|
||||
@@ -862,18 +863,21 @@ class TestItem(ERPNextTestSuite):
|
||||
item.reload()
|
||||
self.assertEqual(item.is_stock_item, 0)
|
||||
|
||||
# Step - 3: Create Product Bundle
|
||||
# Step - 3: Create (and submit) an active Product Bundle for the item
|
||||
component = make_item(properties={"is_stock_item": 1}).name
|
||||
pb = frappe.new_doc("Product Bundle")
|
||||
pb.new_item_code = item.name
|
||||
pb.flags.ignore_mandatory = True
|
||||
pb.save()
|
||||
pb.append("items", {"item_code": component, "qty": 1})
|
||||
pb.insert()
|
||||
pb.submit()
|
||||
|
||||
# Step - 4: Try to enable Maintain Stock, should throw a validation error
|
||||
item.is_stock_item = 1
|
||||
self.assertRaises(frappe.ValidationError, item.save)
|
||||
item.reload()
|
||||
|
||||
# Step - 5: Delete Product Bundle
|
||||
# Step - 5: Cancel & delete Product Bundle
|
||||
pb.cancel()
|
||||
pb.delete()
|
||||
|
||||
# Step - 6: Again try to enable Maintain Stock
|
||||
|
||||
@@ -111,7 +111,9 @@ def make_packing_list(doc):
|
||||
|
||||
|
||||
def is_product_bundle(item_code: str) -> bool:
|
||||
return bool(frappe.db.exists("Product Bundle", {"new_item_code": item_code, "disabled": 0}))
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
return bool(get_active_product_bundle(item_code))
|
||||
|
||||
|
||||
def get_indexed_packed_items_table(doc):
|
||||
@@ -172,7 +174,11 @@ def get_product_bundle_items(item_code):
|
||||
product_bundle_item.uom,
|
||||
product_bundle_item.description,
|
||||
)
|
||||
.where((product_bundle.new_item_code == item_code) & (product_bundle.disabled == 0))
|
||||
.where(
|
||||
(product_bundle.new_item_code == item_code)
|
||||
& (product_bundle.is_active == 1)
|
||||
& (product_bundle.docstatus == 1)
|
||||
)
|
||||
.orderby(product_bundle_item.idx)
|
||||
)
|
||||
return query.run(as_dict=True)
|
||||
|
||||
@@ -36,6 +36,7 @@ def create_product_bundle(
|
||||
make_stock_entry(item=compoenent, to_warehouse=warehouse, qty=10 * qty, rate=100)
|
||||
|
||||
bundle_doc.insert()
|
||||
bundle_doc.submit()
|
||||
|
||||
return bundle, components
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from frappe.query_builder.functions import Coalesce, Locate, Replace, Sum
|
||||
from frappe.utils import cint, floor, flt, get_link_to_form
|
||||
from frappe.utils.nestedset import get_descendants_of
|
||||
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
get_auto_batch_nos,
|
||||
)
|
||||
@@ -650,7 +651,7 @@ class PickList(TransactionBase):
|
||||
frappe.throw(f"Row #{item.idx}: Item Code is Mandatory")
|
||||
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_active_product_bundle(item.item_code):
|
||||
continue
|
||||
item_code = item.item_code
|
||||
reference = item.sales_order_item or item.material_request_item
|
||||
@@ -850,7 +851,7 @@ class PickList(TransactionBase):
|
||||
def _get_product_bundle_qty_map(self, bundles) -> dict[str, dict[str, float]]:
|
||||
product_bundle_qty_map = {}
|
||||
for data in bundles:
|
||||
bundle = frappe.get_last_doc("Product Bundle", {"new_item_code": data.item_code, "disabled": 0})
|
||||
bundle = frappe.get_doc("Product Bundle", get_active_product_bundle(data.item_code))
|
||||
product_bundle_qty_map[data.item_code] = {item.item_code: item.qty for item in bundle.items}
|
||||
return product_bundle_qty_map
|
||||
|
||||
|
||||
@@ -953,10 +953,11 @@
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"description": "Parent item of the Product Bundle this row was packed from",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"options": "Item",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1416,14 +1416,17 @@ def create_repack_entry(**args):
|
||||
|
||||
|
||||
def create_product_bundle_item(new_item_code, packed_items):
|
||||
if not frappe.db.exists("Product Bundle", new_item_code):
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
if not get_active_product_bundle(new_item_code):
|
||||
item = frappe.new_doc("Product Bundle")
|
||||
item.new_item_code = new_item_code
|
||||
|
||||
for d in packed_items:
|
||||
item.append("items", {"item_code": d[0], "qty": d[1]})
|
||||
|
||||
item.save()
|
||||
item.insert()
|
||||
item.submit()
|
||||
|
||||
|
||||
def create_items(items=None, uoms=None):
|
||||
|
||||
@@ -180,9 +180,12 @@ def remove_standard_fields(out: ItemDetails):
|
||||
|
||||
|
||||
def set_valuation_rate(out: ItemDetails | dict, ctx: ItemDetailsCtx):
|
||||
if frappe.db.exists("Product Bundle", {"name": ctx.item_code, "disabled": 0}, cache=True):
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
active_bundle = get_active_product_bundle(ctx.item_code)
|
||||
if active_bundle:
|
||||
valuation_rate = 0.0
|
||||
bundled_items = frappe.get_doc("Product Bundle", ctx.item_code)
|
||||
bundled_items = frappe.get_doc("Product Bundle", active_bundle)
|
||||
|
||||
for bundle_item in bundled_items.items:
|
||||
valuation_rate += flt(
|
||||
|
||||
@@ -139,7 +139,7 @@ def get_items(filters):
|
||||
item.brand,
|
||||
item.stock_uom,
|
||||
)
|
||||
.where(IfNull(item.disabled, 0) == 0)
|
||||
.where((IfNull(item.disabled, 0) == 0) & (pb.is_active == 1) & (pb.docstatus == 1))
|
||||
)
|
||||
|
||||
if item_code := filters.get("item_code"):
|
||||
@@ -181,7 +181,7 @@ def get_items(filters):
|
||||
pbi.uom,
|
||||
pbi.qty,
|
||||
)
|
||||
.where(pb.new_item_code.isin(parent_items))
|
||||
.where(pb.new_item_code.isin(parent_items) & (pb.is_active == 1) & (pb.docstatus == 1))
|
||||
).run(as_dict=1)
|
||||
|
||||
child_items = set()
|
||||
|
||||
Reference in New Issue
Block a user