mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-21 02:09:58 +00:00
feat: record and select Product Bundle version on transactions (#55738)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
"barcode",
|
||||
"has_item_scanned",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"col_break1",
|
||||
"item_name",
|
||||
"customer_item_code",
|
||||
@@ -125,6 +127,23 @@
|
||||
"options": "Item",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.so_detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "col_break1",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -858,7 +877,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-20 16:16:12.322024",
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice Item",
|
||||
|
||||
@@ -886,11 +886,12 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Parent item of the Product Bundle this row was packed from",
|
||||
"description": "Product Bundle version this row was packed from",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Product Bundle",
|
||||
"options": "Item",
|
||||
"options": "Product Bundle",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -1009,7 +1010,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-06 08:08:40.782395",
|
||||
"modified": "2026-06-08 21:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice Item",
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"barcode",
|
||||
"has_item_scanned",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"col_break1",
|
||||
"item_name",
|
||||
"customer_item_code",
|
||||
@@ -144,6 +146,23 @@
|
||||
"options": "Item",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.so_detail || doc.dn_detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "col_break1",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -1036,7 +1055,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-03 13:17:36.145788",
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -845,11 +845,12 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Parent item of the Product Bundle this row was packed from",
|
||||
"description": "Product Bundle version this row was packed from",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Product Bundle",
|
||||
"options": "Item",
|
||||
"options": "Product Bundle",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -940,7 +941,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-20 00:50:16.192936",
|
||||
"modified": "2026-06-08 21:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Item",
|
||||
|
||||
@@ -485,4 +485,4 @@ erpnext.patches.v16_0.set_default_letter_head_for_doctype_and_report
|
||||
erpnext.patches.v16_0.clear_procedures_from_receivable_report
|
||||
erpnext.patches.v16_0.migrate_address_contact_custom_fields
|
||||
erpnext.patches.v16_0.rename_secondary_item_type_field
|
||||
erpnext.patches.v16_0.submit_existing_product_bundles
|
||||
erpnext.patches.v16_0.submit_existing_product_bundles #1
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
"""Make existing Product Bundles submittable & versioned.
|
||||
"""Migrate Product Bundles to the submittable, versioned model (issue #29462).
|
||||
|
||||
Product Bundle became a submittable, versioned doctype (issue #29462). Pre-existing
|
||||
bundles were drafts named after their parent item (``name == new_item_code``). This
|
||||
patch migrates them to the new model:
|
||||
Pre-existing bundles were editable drafts named after their parent item
|
||||
(``name == new_item_code``). This patch:
|
||||
|
||||
1. rename each legacy bundle to the versioned name ``PB-<parent item>-001``
|
||||
2. mark it submitted (``docstatus = 1``)
|
||||
3. seed ``is_active`` from the legacy ``disabled`` flag (active = not disabled)
|
||||
1. renames each legacy bundle to the versioned name ``PB-<parent item>-001``,
|
||||
marks it submitted (``docstatus = 1``) and seeds ``is_active`` from the legacy
|
||||
``disabled`` flag (active = not disabled), and
|
||||
2. stamps the resolved version onto existing transaction rows, so documents keep a
|
||||
reference to the exact bundle version they were packed from.
|
||||
|
||||
No transaction stores a bundle's *name* (they snapshot components into their own
|
||||
``packed_items`` tables and reference the parent item code), so renaming is
|
||||
reference-safe. The patch is idempotent: already-migrated bundles (docstatus != 0 or
|
||||
already prefixed) are skipped.
|
||||
Both steps ship together (v16 is unreleased), so they are a single migration. No
|
||||
transaction stores a bundle's *name* (they snapshot components and reference the
|
||||
parent item code), so renaming is reference-safe. The whole patch is idempotent.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import NAME_PREFIX, build_bundle_name
|
||||
|
||||
# doctype -> column holding the bundle parent item code
|
||||
SELLING_ITEM_TABLES = {
|
||||
"Sales Order Item": "item_code",
|
||||
"Delivery Note Item": "item_code",
|
||||
"Sales Invoice Item": "item_code",
|
||||
"POS Invoice Item": "item_code",
|
||||
"Quotation Item": "item_code",
|
||||
"Packed Item": "parent_item",
|
||||
}
|
||||
|
||||
BUYING_ITEM_TABLES = ["Purchase Order Item", "Purchase Invoice Item", "Purchase Receipt Item"]
|
||||
|
||||
|
||||
def execute():
|
||||
submit_existing_bundles()
|
||||
stamp_versions_on_transactions()
|
||||
|
||||
|
||||
def submit_existing_bundles():
|
||||
legacy_bundles = frappe.get_all(
|
||||
"Product Bundle",
|
||||
filters={"docstatus": 0},
|
||||
@@ -51,6 +68,63 @@ def execute():
|
||||
_enforce_single_active_version()
|
||||
|
||||
|
||||
def stamp_versions_on_transactions():
|
||||
"""Backfill the ``product_bundle`` version link onto existing transaction rows.
|
||||
|
||||
- Selling / packed rows: a row whose item is a bundle parent is stamped with that
|
||||
bundle's version (the field was newly added, so only blank rows are touched) and
|
||||
flagged via ``is_product_bundle`` so the version field stays visible.
|
||||
- Buying rows: the ``product_bundle`` field previously stored the parent *item code*;
|
||||
convert those legacy values to the bundle version name. Idempotent: once converted,
|
||||
the value is a bundle name and no longer matches a ``new_item_code``.
|
||||
"""
|
||||
# parent item code -> migrated bundle version name (active version preferred)
|
||||
version_by_item = {}
|
||||
for bundle in frappe.get_all(
|
||||
"Product Bundle",
|
||||
filters={"docstatus": 1},
|
||||
fields=["name", "new_item_code"],
|
||||
order_by="is_active desc, creation asc",
|
||||
):
|
||||
version_by_item.setdefault(bundle.new_item_code, bundle.name)
|
||||
|
||||
if not version_by_item:
|
||||
return
|
||||
|
||||
for doctype, item_field in SELLING_ITEM_TABLES.items():
|
||||
if not frappe.db.has_column(doctype, "product_bundle"):
|
||||
continue
|
||||
table = frappe.qb.DocType(doctype)
|
||||
item_column = getattr(table, item_field)
|
||||
flag_bundle_rows = frappe.db.has_column(doctype, "is_product_bundle")
|
||||
for item_code, version in version_by_item.items():
|
||||
(
|
||||
frappe.qb.update(table)
|
||||
.set(table.product_bundle, version)
|
||||
.where(
|
||||
(item_column == item_code)
|
||||
& ((table.product_bundle.isnull()) | (table.product_bundle == ""))
|
||||
)
|
||||
).run()
|
||||
if flag_bundle_rows:
|
||||
# keep the version field visible on bundle rows even if its value is cleared
|
||||
(
|
||||
frappe.qb.update(table).set(table.is_product_bundle, 1).where(item_column == item_code)
|
||||
).run()
|
||||
|
||||
for doctype in BUYING_ITEM_TABLES:
|
||||
if not frappe.db.has_column(doctype, "product_bundle"):
|
||||
continue
|
||||
table = frappe.qb.DocType(doctype)
|
||||
for item_code, version in version_by_item.items():
|
||||
# only legacy rows still holding the item code are matched
|
||||
(
|
||||
frappe.qb.update(table)
|
||||
.set(table.product_bundle, version)
|
||||
.where(table.product_bundle == item_code)
|
||||
).run()
|
||||
|
||||
|
||||
def _next_index(item_code: str) -> int:
|
||||
"""Next free version index for a parent item among already-migrated bundles."""
|
||||
existing = frappe.get_all(
|
||||
|
||||
@@ -200,6 +200,20 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
});
|
||||
}
|
||||
|
||||
if (this.frm.fields_dict["items"].grid.get_field("product_bundle")) {
|
||||
// restrict the version picker to submitted Product Bundles of the row's item
|
||||
this.frm.set_query("product_bundle", "items", function (doc, cdt, cdn) {
|
||||
let row = locals[cdt][cdn];
|
||||
|
||||
return {
|
||||
filters: {
|
||||
new_item_code: row.item_code,
|
||||
docstatus: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.frm.docstatus < 2 &&
|
||||
this.frm.fields_dict["payment_terms_template"] &&
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"item_name",
|
||||
"customer_item_code",
|
||||
"col_break1",
|
||||
@@ -100,6 +102,22 @@
|
||||
"search_index": 1,
|
||||
"width": "150px"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle"
|
||||
},
|
||||
{
|
||||
"fieldname": "customer_item_code",
|
||||
"fieldtype": "Data",
|
||||
@@ -711,7 +729,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 12:56:08.320190",
|
||||
"modified": "2026-06-08 19:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Quotation Item",
|
||||
|
||||
@@ -775,7 +775,7 @@ def make_purchase_order(
|
||||
["parent", "sales_order"],
|
||||
["uom", "uom"],
|
||||
["conversion_factor", "conversion_factor"],
|
||||
["parent_item", "product_bundle"],
|
||||
["product_bundle", "product_bundle"],
|
||||
["rate", "rate"],
|
||||
],
|
||||
"field_no_map": [
|
||||
@@ -804,17 +804,20 @@ def make_purchase_order(
|
||||
|
||||
|
||||
def set_delivery_date(items: list, sales_order: str) -> None:
|
||||
# `product_bundle` now holds the Product Bundle *version*, so match the Purchase
|
||||
# Order rows to their originating Sales Order rows by that version.
|
||||
delivery_dates = frappe.get_all(
|
||||
"Sales Order Item", filters={"parent": sales_order}, fields=["delivery_date", "item_code"]
|
||||
"Sales Order Item", filters={"parent": sales_order}, fields=["delivery_date", "product_bundle"]
|
||||
)
|
||||
|
||||
delivery_by_item = frappe._dict()
|
||||
delivery_by_bundle = frappe._dict()
|
||||
for date in delivery_dates:
|
||||
delivery_by_item[date.item_code] = date.delivery_date
|
||||
if date.product_bundle:
|
||||
delivery_by_bundle[date.product_bundle] = date.delivery_date
|
||||
|
||||
for item in items:
|
||||
if item.product_bundle:
|
||||
item.schedule_date = delivery_by_item[item.product_bundle]
|
||||
item.schedule_date = delivery_by_bundle.get(item.product_bundle)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -1415,6 +1415,14 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
self.assertEqual(purchase_order.items[0].item_code, "_Test Bundle Item 1")
|
||||
self.assertEqual(purchase_order.items[1].item_code, "_Test Bundle Item 2")
|
||||
|
||||
# each Purchase Order row records the Product Bundle version it was packed from
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
version = get_active_product_bundle("_Test Product Bundle")
|
||||
self.assertTrue(version and version.startswith("PB-"))
|
||||
self.assertEqual(purchase_order.items[0].product_bundle, version)
|
||||
self.assertEqual(purchase_order.items[1].product_bundle, version)
|
||||
|
||||
def test_purchase_order_updates_packed_item_ordered_qty(self):
|
||||
"""
|
||||
Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"fg_item",
|
||||
"fg_item_qty",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"customer_item_code",
|
||||
"ensure_delivery_based_on_produced_serial_no",
|
||||
"is_stock_item",
|
||||
@@ -136,6 +138,23 @@
|
||||
"reqd": 1,
|
||||
"width": "150px"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.quotation_item"
|
||||
},
|
||||
{
|
||||
"fieldname": "customer_item_code",
|
||||
"fieldtype": "Data",
|
||||
@@ -1036,7 +1055,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-06 12:03:40.472277",
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order Item",
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"barcode",
|
||||
"has_item_scanned",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"item_name",
|
||||
"col_break1",
|
||||
"customer_item_code",
|
||||
@@ -136,6 +138,23 @@
|
||||
"search_index": 1,
|
||||
"width": "150px"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.so_detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "item_name",
|
||||
"fieldtype": "Data",
|
||||
@@ -952,7 +971,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-07 15:43:20.892151",
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Delivery Note Item",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"field_order": [
|
||||
"parent_item",
|
||||
"item_code",
|
||||
"product_bundle",
|
||||
"item_name",
|
||||
"delivered_by_supplier",
|
||||
"reserve_stock",
|
||||
@@ -65,6 +66,16 @@
|
||||
"options": "Item",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.product_bundle",
|
||||
"description": "Product Bundle version this row was packed from",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"no_copy": 1,
|
||||
"options": "Product Bundle",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "item_name",
|
||||
"fieldtype": "Data",
|
||||
@@ -327,7 +338,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-05 16:16:12.856629",
|
||||
"modified": "2026-06-08 15:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Packed Item",
|
||||
|
||||
@@ -84,8 +84,16 @@ 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):
|
||||
for bundle_item in get_product_bundle_items(item_row.item_code):
|
||||
# Pack from the version chosen on the row (default: the item's active version)
|
||||
# and record it so the document keeps a reference to the exact version used.
|
||||
bundle_name = get_bundle_version_for_row(item_row)
|
||||
if item_row.meta.has_field("product_bundle"):
|
||||
item_row.product_bundle = bundle_name
|
||||
if item_row.meta.has_field("is_product_bundle"):
|
||||
item_row.is_product_bundle = 1 if bundle_name else 0
|
||||
|
||||
if bundle_name:
|
||||
for bundle_item in get_product_bundle_items_by_name(bundle_name):
|
||||
pi_row = add_packed_item_row(
|
||||
doc=doc,
|
||||
packing_item=bundle_item,
|
||||
@@ -93,6 +101,7 @@ def make_packing_list(doc):
|
||||
packed_items_table=stale_packed_items_table,
|
||||
reset=reset,
|
||||
)
|
||||
pi_row.product_bundle = bundle_name
|
||||
item_data = get_packed_item_details(bundle_item.item_code, doc.company)
|
||||
update_packed_item_basic_data(item_row, pi_row, bundle_item, item_data)
|
||||
update_packed_item_stock_data(item_row, pi_row, bundle_item, item_data, doc)
|
||||
@@ -146,8 +155,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")]
|
||||
# include product_bundle so picking a different version re-packs the components
|
||||
items_before_save = [
|
||||
(item.name, item.item_code, item.get("product_bundle")) for item in doc_before_save.get("items")
|
||||
]
|
||||
items_after_save = [
|
||||
(item.name, item.item_code, item.get("product_bundle")) for item in doc.get("items")
|
||||
]
|
||||
reset_table = items_before_save != items_after_save
|
||||
else:
|
||||
# reset: if via Update Items OR
|
||||
@@ -184,6 +198,40 @@ def get_product_bundle_items(item_code):
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_product_bundle_items_by_name(bundle_name):
|
||||
"Component rows of a specific Product Bundle version."
|
||||
product_bundle_item = frappe.qb.DocType("Product Bundle Item")
|
||||
return (
|
||||
frappe.qb.from_(product_bundle_item)
|
||||
.select(
|
||||
product_bundle_item.item_code,
|
||||
product_bundle_item.qty,
|
||||
product_bundle_item.uom,
|
||||
product_bundle_item.description,
|
||||
)
|
||||
.where(product_bundle_item.parent == bundle_name)
|
||||
.orderby(product_bundle_item.idx)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
def get_bundle_version_for_row(item_row):
|
||||
"""Product Bundle version to pack ``item_row`` from.
|
||||
|
||||
Honours a version explicitly chosen on the row (validated to be a submitted
|
||||
bundle of that item); otherwise falls back to the item's active version. A stale
|
||||
choice (e.g. left over after changing the item) self-heals back to the active one.
|
||||
"""
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
chosen = item_row.get("product_bundle") if item_row.meta.has_field("product_bundle") else None
|
||||
if chosen:
|
||||
bundle = frappe.db.get_value("Product Bundle", chosen, ["new_item_code", "docstatus"], as_dict=True)
|
||||
if bundle and bundle.new_item_code == item_row.item_code and bundle.docstatus == 1:
|
||||
return chosen
|
||||
|
||||
return get_active_product_bundle(item_row.item_code)
|
||||
|
||||
|
||||
def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table, reset):
|
||||
"""Add and return packed item row.
|
||||
doc: Transaction document
|
||||
|
||||
@@ -77,6 +77,94 @@ class TestPackedItem(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(len(so.packed_items), 0)
|
||||
|
||||
def test_item_and_packed_rows_record_bundle_version(self):
|
||||
"The item row and its packed items record the resolved Product Bundle version."
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
version = get_active_product_bundle(self.bundle)
|
||||
self.assertTrue(version and version.startswith("PB-"))
|
||||
|
||||
so = make_sales_order(item_code=self.bundle, qty=1, warehouse=self.warehouse)
|
||||
self.assertEqual(so.items[0].product_bundle, version)
|
||||
self.assertEqual(so.items[0].is_product_bundle, 1)
|
||||
self.assertEqual(len(so.packed_items), 2)
|
||||
for pi in so.packed_items:
|
||||
self.assertEqual(pi.product_bundle, version)
|
||||
|
||||
# the version carries onto a Delivery Note mapped from the Sales Order
|
||||
dn = make_delivery_note(so.name)
|
||||
self.assertEqual(dn.items[0].product_bundle, version)
|
||||
for pi in dn.packed_items:
|
||||
self.assertEqual(pi.product_bundle, version)
|
||||
|
||||
def test_clearing_version_keeps_bundle_flag_and_redefaults(self):
|
||||
"Clearing the version must not lose the bundle flag (keeps the field visible)."
|
||||
so = make_sales_order(item_code=self.bundle, qty=1, warehouse=self.warehouse, do_not_submit=True)
|
||||
version = so.items[0].product_bundle
|
||||
self.assertEqual(so.items[0].is_product_bundle, 1)
|
||||
|
||||
# user blanks the version field
|
||||
so.items[0].product_bundle = None
|
||||
so.save()
|
||||
|
||||
# the flag stays set (so depends_on keeps the field visible) and the value
|
||||
# re-defaults to the active version
|
||||
self.assertEqual(so.items[0].is_product_bundle, 1)
|
||||
self.assertEqual(so.items[0].product_bundle, version)
|
||||
|
||||
def test_backfill_patch_stamps_existing_rows(self):
|
||||
"The backfill patch stamps the version on rows that predate the field."
|
||||
from erpnext.patches.v16_0.submit_existing_product_bundles import (
|
||||
stamp_versions_on_transactions as stamp_versions,
|
||||
)
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
version = get_active_product_bundle(self.bundle)
|
||||
so = make_sales_order(item_code=self.bundle, qty=1, do_not_submit=True)
|
||||
|
||||
# simulate pre-migration rows with no version recorded and no bundle flag
|
||||
frappe.db.set_value("Sales Order Item", so.items[0].name, "product_bundle", None)
|
||||
frappe.db.set_value("Sales Order Item", so.items[0].name, "is_product_bundle", 0)
|
||||
for pi in so.packed_items:
|
||||
frappe.db.set_value("Packed Item", pi.name, "product_bundle", None)
|
||||
|
||||
stamp_versions()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Sales Order Item", so.items[0].name, "product_bundle"), version)
|
||||
self.assertEqual(frappe.db.get_value("Sales Order Item", so.items[0].name, "is_product_bundle"), 1)
|
||||
for pi in so.packed_items:
|
||||
self.assertEqual(frappe.db.get_value("Packed Item", pi.name, "product_bundle"), version)
|
||||
|
||||
def test_choosing_an_older_version_packs_its_components(self):
|
||||
"Default picks the active version; choosing an older version re-packs its components."
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import (
|
||||
get_active_product_bundle,
|
||||
make_new_version,
|
||||
)
|
||||
|
||||
v1 = get_active_product_bundle(self.bundle)
|
||||
|
||||
# new version with a different component becomes the active one
|
||||
new_component = make_item().name
|
||||
make_stock_entry(item=new_component, to_warehouse=self.warehouse, qty=50, rate=100)
|
||||
v2 = make_new_version(v1)
|
||||
v2.items = []
|
||||
v2.append("items", {"item_code": new_component, "qty": 1})
|
||||
v2.insert()
|
||||
v2.submit()
|
||||
self.assertEqual(get_active_product_bundle(self.bundle), v2.name)
|
||||
|
||||
# default: the active version (v2) and its component
|
||||
so = make_sales_order(item_code=self.bundle, qty=1, warehouse=self.warehouse, do_not_submit=True)
|
||||
self.assertEqual(so.items[0].product_bundle, v2.name)
|
||||
self.assertEqual([pi.item_code for pi in so.packed_items], [new_component])
|
||||
|
||||
# choose the older version -> its components are packed instead
|
||||
so.items[0].product_bundle = v1
|
||||
so.save()
|
||||
self.assertEqual(so.items[0].product_bundle, v1)
|
||||
self.assertEqual(sorted(pi.item_code for pi in so.packed_items), sorted(self.bundle_items))
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
|
||||
def test_recurring_bundle_item(self):
|
||||
"Test impact on packed items if same bundle item is added and removed."
|
||||
|
||||
@@ -953,11 +953,12 @@
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"description": "Parent item of the Product Bundle this row was packed from",
|
||||
"description": "Product Bundle version this row was packed from",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Product Bundle",
|
||||
"options": "Item",
|
||||
"options": "Product Bundle",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -1121,7 +1122,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-04 10:52:43.188507",
|
||||
"modified": "2026-06-08 21:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Purchase Receipt Item",
|
||||
|
||||
@@ -164,6 +164,18 @@ def get_item_details(
|
||||
if ctx.is_subcontracted:
|
||||
out.bom = ctx.bom or get_default_bom(ctx.item_code)
|
||||
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
active_bundle = get_active_product_bundle(ctx.item_code)
|
||||
# flag bundle rows so the version field stays visible regardless of its value
|
||||
out.is_product_bundle = 1 if active_bundle else 0
|
||||
if not active_bundle:
|
||||
out.product_bundle = None
|
||||
elif not ctx.get("product_bundle"):
|
||||
# prefill the active version so a freshly added bundle row defaults to it
|
||||
# (guarded so re-fetches never overwrite a version the user picked)
|
||||
out.product_bundle = active_bundle
|
||||
|
||||
get_gross_profit(out)
|
||||
if ctx.doctype == "Material Request":
|
||||
out.rate = ctx.rate or out.price_list_rate
|
||||
|
||||
Reference in New Issue
Block a user