mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-28 14:18:24 +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:
@@ -21,6 +21,7 @@ from erpnext.accounts.doctype.sales_invoice.services.loyalty import LoyaltyServi
|
|||||||
from erpnext.accounts.party import get_due_date, get_party_account
|
from erpnext.accounts.party import get_due_date, get_party_account
|
||||||
from erpnext.controllers.queries import item_query as _item_query
|
from erpnext.controllers.queries import item_query as _item_query
|
||||||
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
|
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
|
||||||
|
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||||
from erpnext.stock.stock_ledger import is_negative_stock_allowed
|
from erpnext.stock.stock_ledger import is_negative_stock_allowed
|
||||||
|
|
||||||
@@ -403,7 +404,7 @@ class POSInvoice(SalesInvoice):
|
|||||||
|
|
||||||
for d in self.get("items"):
|
for d in self.get("items"):
|
||||||
if not d.serial_and_batch_bundle:
|
if not d.serial_and_batch_bundle:
|
||||||
if frappe.db.exists("Product Bundle", d.item_code):
|
if get_active_product_bundle(d.item_code):
|
||||||
(
|
(
|
||||||
availability,
|
availability,
|
||||||
is_stock_item,
|
is_stock_item,
|
||||||
@@ -916,7 +917,7 @@ def get_stock_availability(item_code: str | None, warehouse: str):
|
|||||||
return bin_qty - pos_sales_qty, is_stock_item, is_negative_stock_allowed(item_code=item_code)
|
return bin_qty - pos_sales_qty, is_stock_item, is_negative_stock_allowed(item_code=item_code)
|
||||||
else:
|
else:
|
||||||
is_stock_item = True
|
is_stock_item = True
|
||||||
if frappe.db.exists("Product Bundle", {"name": item_code, "disabled": 0}):
|
if get_active_product_bundle(item_code):
|
||||||
return get_bundle_availability(item_code, warehouse), is_stock_item, False
|
return get_bundle_availability(item_code, warehouse), is_stock_item, False
|
||||||
else:
|
else:
|
||||||
is_stock_item = False
|
is_stock_item = False
|
||||||
@@ -926,7 +927,7 @@ def get_stock_availability(item_code: str | None, warehouse: str):
|
|||||||
|
|
||||||
def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
|
def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
|
||||||
is_stock_item = True
|
is_stock_item = True
|
||||||
bundle = frappe.get_doc("Product Bundle", item_code)
|
bundle = frappe.get_doc("Product Bundle", get_active_product_bundle(item_code))
|
||||||
availabilities = []
|
availabilities = []
|
||||||
for bundle_item in bundle.items:
|
for bundle_item in bundle.items:
|
||||||
if frappe.get_value("Item", bundle_item.item_code, "is_stock_item"):
|
if frappe.get_value("Item", bundle_item.item_code, "is_stock_item"):
|
||||||
@@ -945,7 +946,7 @@ def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
|
|||||||
|
|
||||||
|
|
||||||
def get_bundle_availability(bundle_item_code, warehouse):
|
def get_bundle_availability(bundle_item_code, warehouse):
|
||||||
product_bundle = frappe.get_doc("Product Bundle", bundle_item_code)
|
product_bundle = frappe.get_doc("Product Bundle", get_active_product_bundle(bundle_item_code))
|
||||||
|
|
||||||
bundle_bin_qty = 1000000
|
bundle_bin_qty = 1000000
|
||||||
for item in product_bundle.items:
|
for item in product_bundle.items:
|
||||||
|
|||||||
@@ -886,10 +886,11 @@
|
|||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"description": "Parent item of the Product Bundle this row was packed from",
|
||||||
"fieldname": "product_bundle",
|
"fieldname": "product_bundle",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Product Bundle",
|
"label": "Product Bundle",
|
||||||
"options": "Product Bundle",
|
"options": "Item",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -845,10 +845,11 @@
|
|||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"description": "Parent item of the Product Bundle this row was packed from",
|
||||||
"fieldname": "product_bundle",
|
"fieldname": "product_bundle",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Product Bundle",
|
"label": "Product Bundle",
|
||||||
"options": "Product Bundle",
|
"options": "Item",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ class SellingController(StockController):
|
|||||||
row.new_item_code
|
row.new_item_code
|
||||||
for row in frappe.get_all(
|
for row in frappe.get_all(
|
||||||
"Product Bundle",
|
"Product Bundle",
|
||||||
filters={"new_item_code": ("in", items_to_fetch), "disabled": 0},
|
filters={"new_item_code": ("in", items_to_fetch), "is_active": 1, "docstatus": 1},
|
||||||
fields="new_item_code",
|
fields="new_item_code",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,3 +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.clear_procedures_from_receivable_report
|
||||||
erpnext.patches.v16_0.migrate_address_contact_custom_fields
|
erpnext.patches.v16_0.migrate_address_contact_custom_fields
|
||||||
erpnext.patches.v16_0.rename_secondary_item_type_field
|
erpnext.patches.v16_0.rename_secondary_item_type_field
|
||||||
|
erpnext.patches.v16_0.submit_existing_product_bundles
|
||||||
|
|||||||
85
erpnext/patches/v16_0/submit_existing_product_bundles.py
Normal file
85
erpnext/patches/v16_0/submit_existing_product_bundles.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Make existing Product Bundles submittable & versioned.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
from erpnext.selling.doctype.product_bundle.product_bundle import NAME_PREFIX, build_bundle_name
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
legacy_bundles = frappe.get_all(
|
||||||
|
"Product Bundle",
|
||||||
|
filters={"docstatus": 0},
|
||||||
|
fields=["name", "new_item_code", "disabled"],
|
||||||
|
order_by="creation asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
for bundle in legacy_bundles:
|
||||||
|
# Submitted bundles are already migrated and excluded by the docstatus filter.
|
||||||
|
# A draft that still carries its legacy name needs renaming; a draft already
|
||||||
|
# named PB-* is the leftover of an interrupted run and only needs submitting.
|
||||||
|
target_name = bundle.name
|
||||||
|
|
||||||
|
if not bundle.name.startswith(f"{NAME_PREFIX}-"):
|
||||||
|
new_name = build_bundle_name(bundle.new_item_code, _next_index(bundle.new_item_code))
|
||||||
|
if not frappe.db.exists("Product Bundle", new_name):
|
||||||
|
frappe.rename_doc(
|
||||||
|
"Product Bundle", bundle.name, new_name, force=True, merge=False, show_alert=False
|
||||||
|
)
|
||||||
|
target_name = new_name
|
||||||
|
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Product Bundle",
|
||||||
|
target_name,
|
||||||
|
{"docstatus": 1, "is_active": 0 if bundle.disabled else 1},
|
||||||
|
update_modified=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
_enforce_single_active_version()
|
||||||
|
|
||||||
|
|
||||||
|
def _next_index(item_code: str) -> int:
|
||||||
|
"""Next free version index for a parent item among already-migrated bundles."""
|
||||||
|
existing = frappe.get_all(
|
||||||
|
"Product Bundle",
|
||||||
|
filters={"new_item_code": item_code, "name": ("like", f"{NAME_PREFIX}-%")},
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
from erpnext.selling.doctype.product_bundle.product_bundle import get_next_version_index
|
||||||
|
|
||||||
|
return get_next_version_index(existing)
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_single_active_version():
|
||||||
|
"""Guarantee at most one active version per parent item.
|
||||||
|
|
||||||
|
Under the old unique-name-per-item invariant duplicates can't exist, so this is a
|
||||||
|
safety net; if several are somehow active, keep the most recently created one.
|
||||||
|
"""
|
||||||
|
active = frappe.get_all(
|
||||||
|
"Product Bundle",
|
||||||
|
filters={"is_active": 1, "docstatus": 1},
|
||||||
|
fields=["name", "new_item_code"],
|
||||||
|
order_by="new_item_code asc, creation desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for bundle in active:
|
||||||
|
if bundle.new_item_code in seen:
|
||||||
|
# a newer version for this item was already kept; deactivate the rest
|
||||||
|
frappe.db.set_value("Product Bundle", bundle.name, "is_active", 0, update_modified=False)
|
||||||
|
else:
|
||||||
|
seen.add(bundle.new_item_code)
|
||||||
@@ -9,5 +9,54 @@ frappe.ui.form.on("Product Bundle", {
|
|||||||
query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code",
|
query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A submitted bundle is immutable. To change it, create a new version
|
||||||
|
// (a fresh draft copied from this one) and submit that instead.
|
||||||
|
if (frm.doc.docstatus === 1) {
|
||||||
|
frm.add_custom_button(__("Create New Version"), () => {
|
||||||
|
frappe.model.open_mapped_doc({
|
||||||
|
method: "erpnext.selling.doctype.product_bundle.product_bundle.make_new_version",
|
||||||
|
frm: frm,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
show_supersede_hint(frm);
|
||||||
|
},
|
||||||
|
|
||||||
|
new_item_code: function (frm) {
|
||||||
|
show_supersede_hint(frm);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function show_supersede_hint(frm) {
|
||||||
|
// Warn (non-blocking) when the chosen Parent Item already has an active bundle:
|
||||||
|
// submitting this draft will create a new version and deactivate that one.
|
||||||
|
frm.set_intro("");
|
||||||
|
if (frm.doc.docstatus !== 0 || !frm.doc.new_item_code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
frappe.db
|
||||||
|
.get_value(
|
||||||
|
"Product Bundle",
|
||||||
|
{
|
||||||
|
new_item_code: frm.doc.new_item_code,
|
||||||
|
is_active: 1,
|
||||||
|
docstatus: 1,
|
||||||
|
},
|
||||||
|
"name"
|
||||||
|
)
|
||||||
|
.then((r) => {
|
||||||
|
const active = r.message && r.message.name;
|
||||||
|
if (active && active !== frm.doc.name) {
|
||||||
|
frm.set_intro(
|
||||||
|
__(
|
||||||
|
"Item {0} already has an active Product Bundle ({1}). Submitting this will create a new version and deactivate {1}.",
|
||||||
|
[frm.doc.new_item_code, active]
|
||||||
|
),
|
||||||
|
"orange"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
"new_item_code",
|
"new_item_code",
|
||||||
"description",
|
"description",
|
||||||
"column_break_eonk",
|
"column_break_eonk",
|
||||||
|
"is_active",
|
||||||
"disabled",
|
"disabled",
|
||||||
|
"amended_from",
|
||||||
"item_section",
|
"item_section",
|
||||||
"items",
|
"items",
|
||||||
"section_break_4",
|
"section_break_4",
|
||||||
@@ -63,11 +65,33 @@
|
|||||||
"fieldtype": "HTML",
|
"fieldtype": "HTML",
|
||||||
"options": "<h3>About Product Bundle</h3>\n\n<p>Aggregate group of <b>Items</b> into another <b>Item</b>. This is useful if you are bundling a certain <b>Items</b> into a package and you maintain stock of the packed <b>Items</b> and not the aggregate <b>Item</b>.</p>\n<p>The package <b>Item</b> will have <code>Is Stock Item</code> as <b>No</b> and <code>Is Sales Item</code> as <b>Yes</b>.</p>\n<h4>Example:</h4>\n<p>If you are selling Laptops and Backpacks separately and have a special price if the customer buys both, then the Laptop + Backpack will be a new Product Bundle Item.</p>"
|
"options": "<h3>About Product Bundle</h3>\n\n<p>Aggregate group of <b>Items</b> into another <b>Item</b>. This is useful if you are bundling a certain <b>Items</b> into a package and you maintain stock of the packed <b>Items</b> and not the aggregate <b>Item</b>.</p>\n<p>The package <b>Item</b> will have <code>Is Stock Item</code> as <b>No</b> and <code>Is Sales Item</code> as <b>Yes</b>.</p>\n<h4>Example:</h4>\n<p>If you are selling Laptops and Backpacks separately and have a special price if the customer buys both, then the Laptop + Backpack will be a new Product Bundle Item.</p>"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"allow_on_submit": 1,
|
||||||
|
"default": "1",
|
||||||
|
"description": "Only one version of a Product Bundle can be active at a time for a given Parent Item. Activating a version deactivates the previously active one.",
|
||||||
|
"fieldname": "is_active",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Is Active",
|
||||||
|
"no_copy": 1
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
|
"depends_on": "disabled",
|
||||||
|
"description": "Deprecated: use Cancel / Is Active instead. Retained for backward compatibility.",
|
||||||
"fieldname": "disabled",
|
"fieldname": "disabled",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Disabled"
|
"label": "Disabled",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "amended_from",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Amended From",
|
||||||
|
"no_copy": 1,
|
||||||
|
"options": "Product Bundle",
|
||||||
|
"print_hide": 1,
|
||||||
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_eonk",
|
"fieldname": "column_break_eonk",
|
||||||
@@ -76,14 +100,17 @@
|
|||||||
],
|
],
|
||||||
"icon": "fa fa-sitemap",
|
"icon": "fa fa-sitemap",
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-03-27 13:10:19.599302",
|
"modified": "2026-06-08 00:00:00.000000",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Selling",
|
"module": "Selling",
|
||||||
"name": "Product Bundle",
|
"name": "Product Bundle",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
|
"amend": 1,
|
||||||
|
"cancel": 1,
|
||||||
"create": 1,
|
"create": 1,
|
||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
@@ -92,6 +119,7 @@
|
|||||||
"report": 1,
|
"report": 1,
|
||||||
"role": "Stock Manager",
|
"role": "Stock Manager",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
|
"submit": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -102,6 +130,8 @@
|
|||||||
"role": "Stock User"
|
"role": "Stock User"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"amend": 1,
|
||||||
|
"cancel": 1,
|
||||||
"create": 1,
|
"create": 1,
|
||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
@@ -110,10 +140,11 @@
|
|||||||
"report": 1,
|
"report": 1,
|
||||||
"role": "Sales User",
|
"role": "Sales User",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
|
"submit": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "ASC",
|
"sort_order": "ASC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
# License: GNU General Public License v3. See license.txt
|
# License: GNU General Public License v3. See license.txt
|
||||||
|
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.query_builder import Criterion
|
from frappe.query_builder import Criterion
|
||||||
from frappe.utils import get_link_to_form
|
from frappe.utils import cint, get_link_to_form
|
||||||
|
|
||||||
|
NAME_PREFIX = "PB"
|
||||||
|
|
||||||
|
|
||||||
class ProductBundle(Document):
|
class ProductBundle(Document):
|
||||||
@@ -20,14 +24,28 @@ class ProductBundle(Document):
|
|||||||
|
|
||||||
from erpnext.selling.doctype.product_bundle_item.product_bundle_item import ProductBundleItem
|
from erpnext.selling.doctype.product_bundle_item.product_bundle_item import ProductBundleItem
|
||||||
|
|
||||||
|
amended_from: DF.Link | None
|
||||||
description: DF.Data | None
|
description: DF.Data | None
|
||||||
disabled: DF.Check
|
disabled: DF.Check
|
||||||
|
is_active: DF.Check
|
||||||
items: DF.Table[ProductBundleItem]
|
items: DF.Table[ProductBundleItem]
|
||||||
new_item_code: DF.Link
|
new_item_code: DF.Link
|
||||||
# end: auto-generated types
|
# end: auto-generated types
|
||||||
|
|
||||||
def autoname(self):
|
def autoname(self):
|
||||||
self.name = self.new_item_code
|
"""BOM-style versioned name: ``PB-<parent item>-001``.
|
||||||
|
|
||||||
|
Amended copies are excluded while computing the current index so that an
|
||||||
|
amendment naturally becomes the next version of the bundle.
|
||||||
|
"""
|
||||||
|
search_key = f"{NAME_PREFIX}-{self.new_item_code}-%"
|
||||||
|
existing = frappe.get_all(
|
||||||
|
"Product Bundle",
|
||||||
|
filters={"name": ("like", search_key), "amended_from": ["is", "not set"]},
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
index = get_next_version_index(existing)
|
||||||
|
self.name = build_bundle_name(self.new_item_code, index)
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
self.validate_main_item()
|
self.validate_main_item()
|
||||||
@@ -37,6 +55,37 @@ class ProductBundle(Document):
|
|||||||
|
|
||||||
validate_uom_is_integer(self, "uom", "qty")
|
validate_uom_is_integer(self, "uom", "qty")
|
||||||
|
|
||||||
|
def on_submit(self):
|
||||||
|
self.make_active()
|
||||||
|
|
||||||
|
def on_cancel(self):
|
||||||
|
self.db_set("is_active", 0)
|
||||||
|
|
||||||
|
def on_update_after_submit(self):
|
||||||
|
# `is_active` is the only field editable after submit; keep a single active
|
||||||
|
# version per parent item in sync when the user (re)activates a version.
|
||||||
|
if self.is_active:
|
||||||
|
self.make_active()
|
||||||
|
|
||||||
|
def make_active(self):
|
||||||
|
"""Mark this version active and deactivate every other submitted version
|
||||||
|
of the same parent item."""
|
||||||
|
if not self.is_active:
|
||||||
|
self.db_set("is_active", 1)
|
||||||
|
|
||||||
|
others = frappe.get_all(
|
||||||
|
"Product Bundle",
|
||||||
|
filters={
|
||||||
|
"new_item_code": self.new_item_code,
|
||||||
|
"is_active": 1,
|
||||||
|
"docstatus": 1,
|
||||||
|
"name": ("!=", self.name),
|
||||||
|
},
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
for name in others:
|
||||||
|
frappe.db.set_value("Product Bundle", name, "is_active", 0)
|
||||||
|
|
||||||
def on_trash(self):
|
def on_trash(self):
|
||||||
linked_doctypes = [
|
linked_doctypes = [
|
||||||
"Delivery Note",
|
"Delivery Note",
|
||||||
@@ -82,7 +131,7 @@ class ProductBundle(Document):
|
|||||||
|
|
||||||
def validate_child_items(self):
|
def validate_child_items(self):
|
||||||
for item in self.items:
|
for item in self.items:
|
||||||
if frappe.db.exists("Product Bundle", {"name": item.item_code, "disabled": 0}):
|
if get_active_product_bundle(item.item_code):
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_(
|
_(
|
||||||
"Row #{0}: Child Item should not be a Product Bundle. Please remove Item {1} and Save"
|
"Row #{0}: Child Item should not be a Product Bundle. Please remove Item {1} and Save"
|
||||||
@@ -99,11 +148,81 @@ class ProductBundle(Document):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_bundle_name(item_code: str, index: int) -> str:
|
||||||
|
"""Build a ``PB-<item>-NNN`` name, truncating the item part to stay within 140 chars."""
|
||||||
|
suffix = "%.3i" % index
|
||||||
|
name = f"{NAME_PREFIX}-{item_code}-{suffix}"
|
||||||
|
if len(name) <= 140:
|
||||||
|
return name
|
||||||
|
|
||||||
|
truncated_length = 140 - (len(NAME_PREFIX) + len(suffix) + 2)
|
||||||
|
truncated_item = item_code[:truncated_length].rsplit(" ", 1)[0]
|
||||||
|
return f"{NAME_PREFIX}-{truncated_item}-{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_version_index(existing_names: list[str]) -> int:
|
||||||
|
"""Highest trailing version index across ``existing_names`` plus one (1 if none)."""
|
||||||
|
pattern = "|".join(re.escape(delim) for delim in ("/", "-"))
|
||||||
|
parts = [re.split(pattern, name) for name in existing_names]
|
||||||
|
valid = [p for p in parts if len(p) > 1 and p[-1]]
|
||||||
|
if not valid:
|
||||||
|
return 1
|
||||||
|
return max(cint(p[-1]) for p in valid) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_product_bundle(item_code: str) -> str | None:
|
||||||
|
"""Return the name of the active, submitted Product Bundle for ``item_code``, else None.
|
||||||
|
|
||||||
|
This is the single resolution entry point for every consumer of bundles; it
|
||||||
|
replaces the legacy ``exists("Product Bundle", {name/new_item_code, disabled: 0})``
|
||||||
|
lookups that assumed one mutable bundle per item.
|
||||||
|
"""
|
||||||
|
if not item_code:
|
||||||
|
return None
|
||||||
|
return frappe.db.get_value(
|
||||||
|
"Product Bundle",
|
||||||
|
{"new_item_code": item_code, "is_active": 1, "docstatus": 1},
|
||||||
|
"name",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def make_new_version(source_name: str, target_doc: str | None = None):
|
||||||
|
"""Create a fresh draft bundle copied from an existing (typically submitted) one.
|
||||||
|
|
||||||
|
The copy keeps the same parent item and component rows but gets a new version
|
||||||
|
name on submit; it does not carry over docstatus or the active flag.
|
||||||
|
"""
|
||||||
|
from frappe.model.mapper import get_mapped_doc
|
||||||
|
|
||||||
|
def post_process(source, target):
|
||||||
|
target.is_active = 1
|
||||||
|
target.disabled = 0
|
||||||
|
|
||||||
|
return get_mapped_doc(
|
||||||
|
"Product Bundle",
|
||||||
|
source_name,
|
||||||
|
{
|
||||||
|
"Product Bundle": {
|
||||||
|
"doctype": "Product Bundle",
|
||||||
|
"field_map": {"new_item_code": "new_item_code"},
|
||||||
|
"field_no_map": ["amended_from", "is_active", "disabled"],
|
||||||
|
},
|
||||||
|
"Product Bundle Item": {
|
||||||
|
"doctype": "Product Bundle Item",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
target_doc,
|
||||||
|
post_process,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@frappe.validate_and_sanitize_search_inputs
|
@frappe.validate_and_sanitize_search_inputs
|
||||||
def get_new_item_code(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
def get_new_item_code(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||||
product_bundles = frappe.db.get_list("Product Bundle", {"disabled": 0}, pluck="name")
|
# Items that already have a bundle are intentionally *not* excluded: creating a
|
||||||
|
# bundle for such an item produces a new version that supersedes the active one
|
||||||
|
# on submit (same as the "Create New Version" action).
|
||||||
if not searchfield or searchfield == "name":
|
if not searchfield or searchfield == "name":
|
||||||
searchfield = frappe.get_meta("Item").get("search_fields")
|
searchfield = frappe.get_meta("Item").get("search_fields")
|
||||||
|
|
||||||
@@ -122,7 +241,4 @@ def get_new_item_code(doctype: str, txt: str, searchfield: str, start: int, page
|
|||||||
if searchfield:
|
if searchfield:
|
||||||
query = query.where(Criterion.any([item[fieldname].like(f"%{txt}%") for fieldname in searchfield]))
|
query = query.where(Criterion.any([item[fieldname].like(f"%{txt}%") for fieldname in searchfield]))
|
||||||
|
|
||||||
if product_bundles:
|
|
||||||
query = query.where(item.name.notin(product_bundles))
|
|
||||||
|
|
||||||
return query.run()
|
return query.run()
|
||||||
|
|||||||
@@ -3,10 +3,22 @@
|
|||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
|
||||||
|
from erpnext.selling.doctype.product_bundle.product_bundle import (
|
||||||
|
get_active_product_bundle,
|
||||||
|
make_new_version,
|
||||||
|
)
|
||||||
|
from erpnext.stock.doctype.item.test_item import make_item
|
||||||
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|
||||||
|
|
||||||
def make_product_bundle(parent, items, qty=None):
|
def make_product_bundle(parent, items, qty=None):
|
||||||
if frappe.db.exists("Product Bundle", parent):
|
"""Create (and submit) an active Product Bundle for ``parent``.
|
||||||
return frappe.get_doc("Product Bundle", parent)
|
|
||||||
|
Product Bundle is now submittable & versioned, so the active version is resolved
|
||||||
|
by parent item code rather than by document name.
|
||||||
|
"""
|
||||||
|
if active := get_active_product_bundle(parent):
|
||||||
|
return frappe.get_doc("Product Bundle", active)
|
||||||
|
|
||||||
product_bundle = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": parent})
|
product_bundle = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": parent})
|
||||||
|
|
||||||
@@ -14,5 +26,87 @@ def make_product_bundle(parent, items, qty=None):
|
|||||||
product_bundle.append("items", {"item_code": item, "qty": qty or 1})
|
product_bundle.append("items", {"item_code": item, "qty": qty or 1})
|
||||||
|
|
||||||
product_bundle.insert()
|
product_bundle.insert()
|
||||||
|
product_bundle.submit()
|
||||||
|
|
||||||
return product_bundle
|
return product_bundle
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductBundle(ERPNextTestSuite):
|
||||||
|
def setUp(self):
|
||||||
|
self.parent = make_item("_Test PB Parent", {"is_stock_item": 0, "is_sales_item": 1}).name
|
||||||
|
make_item("_Test PB Child A", {"is_stock_item": 1})
|
||||||
|
make_item("_Test PB Child B", {"is_stock_item": 1})
|
||||||
|
|
||||||
|
def test_submit_makes_bundle_active_and_versioned(self):
|
||||||
|
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||||
|
self.assertEqual(bundle.docstatus, 1)
|
||||||
|
self.assertEqual(bundle.is_active, 1)
|
||||||
|
self.assertTrue(bundle.name.startswith("PB-"))
|
||||||
|
self.assertEqual(get_active_product_bundle(self.parent), bundle.name)
|
||||||
|
|
||||||
|
def test_new_version_deactivates_previous(self):
|
||||||
|
v1 = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||||
|
|
||||||
|
v2 = make_new_version(v1.name)
|
||||||
|
v2.items[0].qty = 5
|
||||||
|
v2.insert()
|
||||||
|
v2.submit()
|
||||||
|
|
||||||
|
self.assertNotEqual(v1.name, v2.name)
|
||||||
|
self.assertEqual(get_active_product_bundle(self.parent), v2.name)
|
||||||
|
self.assertEqual(frappe.db.get_value("Product Bundle", v1.name, "is_active"), 0)
|
||||||
|
|
||||||
|
def test_reactivating_old_version_deactivates_current(self):
|
||||||
|
v1 = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||||
|
|
||||||
|
v2 = make_new_version(v1.name)
|
||||||
|
v2.items[0].qty = 5
|
||||||
|
v2.insert()
|
||||||
|
v2.submit()
|
||||||
|
self.assertEqual(get_active_product_bundle(self.parent), v2.name)
|
||||||
|
|
||||||
|
# switch back to v1 by toggling is_active on the submitted doc (allow_on_submit)
|
||||||
|
v1.reload()
|
||||||
|
v1.is_active = 1
|
||||||
|
v1.save()
|
||||||
|
|
||||||
|
self.assertEqual(get_active_product_bundle(self.parent), v1.name)
|
||||||
|
self.assertEqual(frappe.db.get_value("Product Bundle", v2.name, "is_active"), 0)
|
||||||
|
|
||||||
|
def test_new_bundle_from_scratch_supersedes_existing(self):
|
||||||
|
# An item that already has a bundle must remain selectable so a new version
|
||||||
|
# can be created straight from the New Product Bundle form.
|
||||||
|
from erpnext.selling.doctype.product_bundle.product_bundle import get_new_item_code
|
||||||
|
|
||||||
|
v1 = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||||
|
|
||||||
|
picker = [row[0] for row in get_new_item_code("Item", self.parent, "name", 0, 20, {})]
|
||||||
|
self.assertIn(self.parent, picker)
|
||||||
|
|
||||||
|
v2 = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": self.parent})
|
||||||
|
v2.append("items", {"item_code": "_Test PB Child B", "qty": 1})
|
||||||
|
v2.insert()
|
||||||
|
v2.submit()
|
||||||
|
|
||||||
|
self.assertNotEqual(v1.name, v2.name)
|
||||||
|
self.assertEqual(get_active_product_bundle(self.parent), v2.name)
|
||||||
|
self.assertEqual(frappe.db.get_value("Product Bundle", v1.name, "is_active"), 0)
|
||||||
|
|
||||||
|
def test_cancel_clears_active(self):
|
||||||
|
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||||
|
bundle.cancel()
|
||||||
|
self.assertEqual(frappe.db.get_value("Product Bundle", bundle.name, "is_active"), 0)
|
||||||
|
self.assertIsNone(get_active_product_bundle(self.parent))
|
||||||
|
|
||||||
|
def test_submitted_bundle_is_immutable(self):
|
||||||
|
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||||
|
bundle.items[0].qty = 99
|
||||||
|
self.assertRaises(frappe.exceptions.UpdateAfterSubmitError, bundle.save)
|
||||||
|
|
||||||
|
def test_child_cannot_be_active_bundle(self):
|
||||||
|
make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||||
|
outer = make_item("_Test PB Outer", {"is_stock_item": 0, "is_sales_item": 1}).name
|
||||||
|
|
||||||
|
doc = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": outer})
|
||||||
|
doc.append("items", {"item_code": self.parent, "qty": 1})
|
||||||
|
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from erpnext.manufacturing.doctype.production_plan.production_plan import (
|
|||||||
get_items_for_material_requests,
|
get_items_for_material_requests,
|
||||||
get_sales_orders,
|
get_sales_orders,
|
||||||
)
|
)
|
||||||
|
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||||
from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle, make_packing_list
|
from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle, make_packing_list
|
||||||
@@ -71,8 +72,15 @@ def make_material_request(source_name: str, target_doc: str | Document | None =
|
|||||||
"Sales Order Item", {"name": so_item.parent_detail_docname}, ["delivered_qty"]
|
"Sales Order Item", {"name": so_item.parent_detail_docname}, ["delivered_qty"]
|
||||||
)
|
)
|
||||||
|
|
||||||
bundle_item_qty = frappe.db.get_value(
|
bundle_name = get_active_product_bundle(so_item.parent_item)
|
||||||
"Product Bundle Item", {"parent": so_item.parent_item, "item_code": so_item.item_code}, ["qty"]
|
bundle_item_qty = (
|
||||||
|
frappe.db.get_value(
|
||||||
|
"Product Bundle Item",
|
||||||
|
{"parent": bundle_name, "item_code": so_item.item_code},
|
||||||
|
["qty"],
|
||||||
|
)
|
||||||
|
if bundle_name
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
return flt(
|
return flt(
|
||||||
@@ -133,9 +141,7 @@ def make_material_request(source_name: str, target_doc: str | Document | None =
|
|||||||
"delivery_date": "schedule_date",
|
"delivery_date": "schedule_date",
|
||||||
"bom_no": "bom_no",
|
"bom_no": "bom_no",
|
||||||
},
|
},
|
||||||
"condition": lambda item: not frappe.db.exists(
|
"condition": lambda item: not is_product_bundle(item.item_code)
|
||||||
"Product Bundle", {"name": item.item_code, "disabled": 0}
|
|
||||||
)
|
|
||||||
and get_remaining_qty(item) > 0,
|
and get_remaining_qty(item) > 0,
|
||||||
"postprocess": update_item,
|
"postprocess": update_item,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -326,9 +326,15 @@ class SalesOrder(SellingController):
|
|||||||
d.projected_qty = bin_data.get((d.item_code, d.warehouse), 0.0)
|
d.projected_qty = bin_data.get((d.item_code, d.warehouse), 0.0)
|
||||||
|
|
||||||
def product_bundle_has_stock_item(self, product_bundle):
|
def product_bundle_has_stock_item(self, product_bundle):
|
||||||
"""Returns true if product bundle has stock item"""
|
"""Returns true if the active bundle for `product_bundle` (a parent item code) has a stock item"""
|
||||||
|
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||||
|
|
||||||
|
bundle_name = get_active_product_bundle(product_bundle)
|
||||||
|
if not bundle_name:
|
||||||
|
return False
|
||||||
|
|
||||||
bundle_items = frappe.get_all(
|
bundle_items = frappe.get_all(
|
||||||
"Product Bundle Item", filters={"parent": product_bundle}, pluck="item_code"
|
"Product Bundle Item", filters={"parent": bundle_name}, pluck="item_code"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not bundle_items:
|
if not bundle_items:
|
||||||
@@ -807,7 +813,9 @@ def get_work_order_items(sales_order: str, for_raw_material_request: int = 0):
|
|||||||
product_bundle_parents = [
|
product_bundle_parents = [
|
||||||
pb.new_item_code
|
pb.new_item_code
|
||||||
for pb in frappe.get_all(
|
for pb in frappe.get_all(
|
||||||
"Product Bundle", {"new_item_code": ["in", item_codes], "disabled": 0}, ["new_item_code"]
|
"Product Bundle",
|
||||||
|
{"new_item_code": ["in", item_codes], "is_active": 1, "docstatus": 1},
|
||||||
|
["new_item_code"],
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class TestSalesOrder(ERPNextTestSuite):
|
|||||||
product_bundle = make_product_bundle(
|
product_bundle = make_product_bundle(
|
||||||
"_Test Product Bundle Item", ["_Test Item", "_Test Item Home Desktop 100"]
|
"_Test Product Bundle Item", ["_Test Item", "_Test Item Home Desktop 100"]
|
||||||
)
|
)
|
||||||
so = make_sales_order(item_code=product_bundle.name, qty=2)
|
so = make_sales_order(item_code=product_bundle.new_item_code, qty=2)
|
||||||
mr = make_material_request(so.name)
|
mr = make_material_request(so.name)
|
||||||
mr.items[0].qty = 4
|
mr.items[0].qty = 4
|
||||||
mr.items[1].qty = 2
|
mr.items[1].qty = 2
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ def get_item_warehouse_quantity_map():
|
|||||||
FROM tabBin AS bi, (SELECT pb.new_item_code as parent, b.item_code, b.qty, w.name
|
FROM tabBin AS bi, (SELECT pb.new_item_code as parent, b.item_code, b.qty, w.name
|
||||||
FROM `tabProduct Bundle Item` b, `tabWarehouse` w,
|
FROM `tabProduct Bundle Item` b, `tabWarehouse` w,
|
||||||
`tabProduct Bundle` pb
|
`tabProduct Bundle` pb
|
||||||
where b.parent = pb.name) AS b
|
where b.parent = pb.name
|
||||||
|
and pb.is_active = 1 and pb.docstatus = 1) AS b
|
||||||
WHERE bi.item_code = b.item_code
|
WHERE bi.item_code = b.item_code
|
||||||
AND bi.warehouse = b.name
|
AND bi.warehouse = b.name
|
||||||
GROUP BY b.parent, b.item_code, bi.warehouse
|
GROUP BY b.parent, b.item_code, bi.warehouse
|
||||||
@@ -80,7 +81,8 @@ def get_item_warehouse_quantity_map():
|
|||||||
FROM (SELECT pb.new_item_code as parent, b.item_code, b.qty, w.name
|
FROM (SELECT pb.new_item_code as parent, b.item_code, b.qty, w.name
|
||||||
FROM `tabProduct Bundle Item` b, `tabWarehouse` w,
|
FROM `tabProduct Bundle Item` b, `tabWarehouse` w,
|
||||||
`tabProduct Bundle` pb
|
`tabProduct Bundle` pb
|
||||||
where b.parent = pb.name) AS b
|
where b.parent = pb.name
|
||||||
|
and pb.is_active = 1 and pb.docstatus = 1) AS b
|
||||||
WHERE NOT EXISTS(SELECT *
|
WHERE NOT EXISTS(SELECT *
|
||||||
FROM `tabBin` AS bi
|
FROM `tabBin` AS bi
|
||||||
WHERE bi.item_code = b.item_code
|
WHERE bi.item_code = b.item_code
|
||||||
|
|||||||
@@ -144,7 +144,9 @@ def get_data():
|
|||||||
|
|
||||||
def get_items_with_product_bundle(item_list):
|
def get_items_with_product_bundle(item_list):
|
||||||
bundled_items = frappe.get_all(
|
bundled_items = frappe.get_all(
|
||||||
"Product Bundle", filters=[("new_item_code", "IN", item_list)], fields=["new_item_code"]
|
"Product Bundle",
|
||||||
|
filters=[("new_item_code", "IN", item_list), ("is_active", "=", 1), ("docstatus", "=", 1)],
|
||||||
|
fields=["new_item_code"],
|
||||||
)
|
)
|
||||||
|
|
||||||
return [d.new_item_code for d in bundled_items]
|
return [d.new_item_code for d in bundled_items]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from frappe.utils import flt
|
|||||||
|
|
||||||
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date
|
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.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:
|
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,
|
"postprocess": update_item,
|
||||||
"condition": lambda item: (
|
"condition": lambda item: (
|
||||||
not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code, "disabled": 0})
|
not is_product_bundle(item.item_code) and flt(item.packed_qty) < flt(item.qty)
|
||||||
and flt(item.packed_qty) < flt(item.qty)
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"Packed Item": {
|
"Packed Item": {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class PackingService:
|
|||||||
items_list = [item.item_code for item in self.doc.items]
|
items_list = [item.item_code for item in self.doc.items]
|
||||||
return frappe.db.get_all(
|
return frappe.db.get_all(
|
||||||
"Product Bundle",
|
"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",
|
pluck="name",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -712,8 +712,10 @@ class Item(Document):
|
|||||||
|
|
||||||
def validate_duplicate_product_bundles_before_merge(self, old_name, new_name):
|
def validate_duplicate_product_bundles_before_merge(self, old_name, new_name):
|
||||||
"Block merge if both old and new items have product bundles."
|
"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})
|
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||||
new_bundle = frappe.get_value("Product Bundle", filters={"new_item_code": new_name, "disabled": 0})
|
|
||||||
|
old_bundle = get_active_product_bundle(old_name)
|
||||||
|
new_bundle = get_active_product_bundle(new_name)
|
||||||
|
|
||||||
if old_bundle and new_bundle:
|
if old_bundle and new_bundle:
|
||||||
bundle_link = get_link_to_form("Product Bundle", old_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 in ("Product Bundle", "BOM"):
|
||||||
if doctype == "Product Bundle":
|
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"
|
fieldname = "new_item_code as docname"
|
||||||
else:
|
else:
|
||||||
filters = {"item": self.name, "docstatus": 1}
|
filters = {"item": self.name, "docstatus": 1}
|
||||||
|
|||||||
@@ -542,6 +542,7 @@ class TestItem(ERPNextTestSuite):
|
|||||||
with self.assertRaises(DataValidationError):
|
with self.assertRaises(DataValidationError):
|
||||||
frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True)
|
frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True)
|
||||||
|
|
||||||
|
bundle1.cancel()
|
||||||
bundle1.delete()
|
bundle1.delete()
|
||||||
frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True)
|
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()
|
item.reload()
|
||||||
self.assertEqual(item.is_stock_item, 0)
|
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 = frappe.new_doc("Product Bundle")
|
||||||
pb.new_item_code = item.name
|
pb.new_item_code = item.name
|
||||||
pb.flags.ignore_mandatory = True
|
pb.append("items", {"item_code": component, "qty": 1})
|
||||||
pb.save()
|
pb.insert()
|
||||||
|
pb.submit()
|
||||||
|
|
||||||
# Step - 4: Try to enable Maintain Stock, should throw a validation error
|
# Step - 4: Try to enable Maintain Stock, should throw a validation error
|
||||||
item.is_stock_item = 1
|
item.is_stock_item = 1
|
||||||
self.assertRaises(frappe.ValidationError, item.save)
|
self.assertRaises(frappe.ValidationError, item.save)
|
||||||
item.reload()
|
item.reload()
|
||||||
|
|
||||||
# Step - 5: Delete Product Bundle
|
# Step - 5: Cancel & delete Product Bundle
|
||||||
|
pb.cancel()
|
||||||
pb.delete()
|
pb.delete()
|
||||||
|
|
||||||
# Step - 6: Again try to enable Maintain Stock
|
# 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:
|
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):
|
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.uom,
|
||||||
product_bundle_item.description,
|
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)
|
.orderby(product_bundle_item.idx)
|
||||||
)
|
)
|
||||||
return query.run(as_dict=True)
|
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)
|
make_stock_entry(item=compoenent, to_warehouse=warehouse, qty=10 * qty, rate=100)
|
||||||
|
|
||||||
bundle_doc.insert()
|
bundle_doc.insert()
|
||||||
|
bundle_doc.submit()
|
||||||
|
|
||||||
return bundle, components
|
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 import cint, floor, flt, get_link_to_form
|
||||||
from frappe.utils.nestedset import get_descendants_of
|
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 (
|
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||||
get_auto_batch_nos,
|
get_auto_batch_nos,
|
||||||
)
|
)
|
||||||
@@ -650,7 +651,7 @@ class PickList(TransactionBase):
|
|||||||
frappe.throw(f"Row #{item.idx}: Item Code is Mandatory")
|
frappe.throw(f"Row #{item.idx}: Item Code is Mandatory")
|
||||||
if not cint(
|
if not cint(
|
||||||
frappe.get_cached_value("Item", item.item_code, "is_stock_item")
|
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
|
continue
|
||||||
item_code = item.item_code
|
item_code = item.item_code
|
||||||
reference = item.sales_order_item or item.material_request_item
|
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]]:
|
def _get_product_bundle_qty_map(self, bundles) -> dict[str, dict[str, float]]:
|
||||||
product_bundle_qty_map = {}
|
product_bundle_qty_map = {}
|
||||||
for data in bundles:
|
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}
|
product_bundle_qty_map[data.item_code] = {item.item_code: item.qty for item in bundle.items}
|
||||||
return product_bundle_qty_map
|
return product_bundle_qty_map
|
||||||
|
|
||||||
|
|||||||
@@ -953,10 +953,11 @@
|
|||||||
"search_index": 1
|
"search_index": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"description": "Parent item of the Product Bundle this row was packed from",
|
||||||
"fieldname": "product_bundle",
|
"fieldname": "product_bundle",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Product Bundle",
|
"label": "Product Bundle",
|
||||||
"options": "Product Bundle",
|
"options": "Item",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1416,14 +1416,17 @@ def create_repack_entry(**args):
|
|||||||
|
|
||||||
|
|
||||||
def create_product_bundle_item(new_item_code, packed_items):
|
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 = frappe.new_doc("Product Bundle")
|
||||||
item.new_item_code = new_item_code
|
item.new_item_code = new_item_code
|
||||||
|
|
||||||
for d in packed_items:
|
for d in packed_items:
|
||||||
item.append("items", {"item_code": d[0], "qty": d[1]})
|
item.append("items", {"item_code": d[0], "qty": d[1]})
|
||||||
|
|
||||||
item.save()
|
item.insert()
|
||||||
|
item.submit()
|
||||||
|
|
||||||
|
|
||||||
def create_items(items=None, uoms=None):
|
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):
|
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
|
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:
|
for bundle_item in bundled_items.items:
|
||||||
valuation_rate += flt(
|
valuation_rate += flt(
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ def get_items(filters):
|
|||||||
item.brand,
|
item.brand,
|
||||||
item.stock_uom,
|
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"):
|
if item_code := filters.get("item_code"):
|
||||||
@@ -181,7 +181,7 @@ def get_items(filters):
|
|||||||
pbi.uom,
|
pbi.uom,
|
||||||
pbi.qty,
|
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)
|
).run(as_dict=1)
|
||||||
|
|
||||||
child_items = set()
|
child_items = set()
|
||||||
|
|||||||
77
erpnext/tests/test_submit_existing_product_bundles.py
Normal file
77
erpnext/tests/test_submit_existing_product_bundles.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# License: GNU General Public License v3. See license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
from erpnext.patches.v16_0.submit_existing_product_bundles import execute
|
||||||
|
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||||
|
from erpnext.stock.doctype.item.test_item import make_item
|
||||||
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubmitExistingProductBundles(ERPNextTestSuite):
|
||||||
|
def _make_legacy_bundle(self, item_code, child, disabled=0):
|
||||||
|
"""Recreate the pre-migration shape: a draft bundle named after its parent item."""
|
||||||
|
bundle = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Product Bundle",
|
||||||
|
"new_item_code": item_code,
|
||||||
|
"items": [{"item_code": child, "qty": 1}],
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
# revert to the legacy state: name == item_code, draft
|
||||||
|
frappe.rename_doc("Product Bundle", bundle.name, item_code, force=True, show_alert=False)
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Product Bundle", item_code, {"docstatus": 0, "disabled": disabled}, update_modified=False
|
||||||
|
)
|
||||||
|
return item_code
|
||||||
|
|
||||||
|
def test_patch_renames_and_submits_legacy_bundle(self):
|
||||||
|
parent = make_item("_Test Patch PB Parent", {"is_stock_item": 0, "is_sales_item": 1}).name
|
||||||
|
child = make_item("_Test Patch PB Child", {"is_stock_item": 1}).name
|
||||||
|
legacy = self._make_legacy_bundle(parent, child)
|
||||||
|
|
||||||
|
execute()
|
||||||
|
|
||||||
|
# legacy name is gone; an active versioned bundle now resolves for the item
|
||||||
|
self.assertFalse(frappe.db.exists("Product Bundle", legacy))
|
||||||
|
migrated = get_active_product_bundle(parent)
|
||||||
|
self.assertTrue(migrated and migrated.startswith("PB-"))
|
||||||
|
self.assertEqual(frappe.db.get_value("Product Bundle", migrated, "docstatus"), 1)
|
||||||
|
self.assertEqual(frappe.db.get_value("Product Bundle", migrated, "is_active"), 1)
|
||||||
|
|
||||||
|
def test_patch_seeds_is_active_from_disabled(self):
|
||||||
|
parent = make_item("_Test Patch PB Disabled Parent", {"is_stock_item": 0, "is_sales_item": 1}).name
|
||||||
|
child = make_item("_Test Patch PB Disabled Child", {"is_stock_item": 1}).name
|
||||||
|
self._make_legacy_bundle(parent, child, disabled=1)
|
||||||
|
|
||||||
|
execute()
|
||||||
|
|
||||||
|
# a disabled legacy bundle becomes submitted but inactive
|
||||||
|
self.assertIsNone(get_active_product_bundle(parent))
|
||||||
|
migrated = frappe.db.get_value("Product Bundle", {"new_item_code": parent, "docstatus": 1}, "name")
|
||||||
|
self.assertTrue(migrated and migrated.startswith("PB-"))
|
||||||
|
self.assertEqual(frappe.db.get_value("Product Bundle", migrated, "is_active"), 0)
|
||||||
|
|
||||||
|
def test_patch_submits_partially_migrated_bundle(self):
|
||||||
|
"""An interrupted run can leave a bundle renamed (PB-*) but still a draft;
|
||||||
|
re-running the patch must submit it rather than skip it."""
|
||||||
|
parent = make_item("_Test Patch PB Partial Parent", {"is_stock_item": 0, "is_sales_item": 1}).name
|
||||||
|
child = make_item("_Test Patch PB Partial Child", {"is_stock_item": 1}).name
|
||||||
|
|
||||||
|
# a freshly inserted (unsubmitted) bundle is already PB-named: exactly the
|
||||||
|
# renamed-but-not-submitted state of an interrupted migration
|
||||||
|
bundle = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Product Bundle",
|
||||||
|
"new_item_code": parent,
|
||||||
|
"items": [{"item_code": child, "qty": 1}],
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
self.assertTrue(bundle.name.startswith("PB-"))
|
||||||
|
self.assertEqual(bundle.docstatus, 0)
|
||||||
|
|
||||||
|
execute()
|
||||||
|
|
||||||
|
self.assertEqual(get_active_product_bundle(parent), bundle.name)
|
||||||
|
self.assertEqual(frappe.db.get_value("Product Bundle", bundle.name, "docstatus"), 1)
|
||||||
@@ -1916,7 +1916,12 @@ class BootStrapTestData:
|
|||||||
self.make_records(["item_code", "item_name"], records)
|
self.make_records(["item_code", "item_name"], records)
|
||||||
|
|
||||||
def make_product_bundle(self):
|
def make_product_bundle(self):
|
||||||
records = [
|
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||||
|
|
||||||
|
if get_active_product_bundle("_Test Product Bundle Item"):
|
||||||
|
return
|
||||||
|
|
||||||
|
frappe.get_doc(
|
||||||
{
|
{
|
||||||
"doctype": "Product Bundle",
|
"doctype": "Product Bundle",
|
||||||
"new_item_code": "_Test Product Bundle Item",
|
"new_item_code": "_Test Product Bundle Item",
|
||||||
@@ -1935,8 +1940,7 @@ class BootStrapTestData:
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
]
|
).insert().submit()
|
||||||
self.make_records(["new_item_code"], records)
|
|
||||||
|
|
||||||
def make_test_account(self):
|
def make_test_account(self):
|
||||||
records = [
|
records = [
|
||||||
|
|||||||
Reference in New Issue
Block a user