Compare commits

...

7 Commits

Author SHA1 Message Date
Mihir Kandoi
f2ec60b7a5 Merge pull request #57629 from frappe/mergify/bp/version-15-hotfix/pr-57616
fix: seed standard Item Groups under the existing tree root (backport #57616)
2026-07-30 19:46:00 +05:30
Mihir Kandoi
1602639a80 fix: resolve backport conflicts for version-15
install() holds the preset list inline on this branch, so the root is
resolved there instead of in get_preset_records. Dropping the preset-record
test with it -- there is no seam to call without running the whole installer.

The patch test is adapted to this branch: TestItem does not roll back between
tests, so it restores the original root name, and it passes parent_item_group
explicitly since ItemGroup.validate skips root-defaulting under
frappe.flags.in_test.
2026-07-30 19:22:29 +05:30
Mihir Kandoi
848335086c fix: seed standard Item Groups under the existing tree root
install_fixtures always inserted "All Item Groups" as a parentless group.
On a site where another app had already created the root, ItemGroup.validate
re-parented it, leaving a second group-root that held the standard groups
while the real root held everything else.

This is reproducible with the healthcare app on a non-English site: its
after_install seeds the root as _("All Item Groups"), so a pt-BR site gets
"Todos os Grupos de Itens" as the root before the setup wizard runs. The
split predates #57390 -- the old translated-name lookup resolved to the same
root and produced an identical tree.

Resolve the root once with get_root_of (falling back to the canonical English
name on fresh installs) and use it for the root record's exists-guard and the
standard groups' parent, matching Company.create_default_departments.

Patch merges an already-seeded "All Item Groups" into the root it sits under,
lifting its children and repointing every link.

Closes #57581

(cherry picked from commit e7088d8981)

# Conflicts:
#	erpnext/setup/doctype/item_group/test_item_group.py
#	erpnext/setup/setup_wizard/operations/install_fixtures.py
2026-07-30 13:41:45 +00:00
Shllokkk
9a596594da Merge pull request #57619 from Shllokkk/asset-manual-create-valuation-rate-v15
fix: source manually created asset value from valuation rate
2026-07-30 14:58:42 +05:30
Shllokkk
455d6d4ac1 fix: source manually created asset value from valuation rate 2026-07-30 14:31:54 +05:30
mergify[bot]
7cecff9fa4 fix: let Purchase Receipt cancel defer to Frappe's linked-document check (backport #57592) (#57602)
fix: let Purchase Receipt cancel defer to Frappe's linked-document check (#57592)

on_cancel pre-blocked cancellation with its own "Purchase Invoice is
already submitted" guard, duplicating the check Frappe already runs for any
submitted linked document. Drop the guard and the unused check_next_docstatus()
method it mirrored so the receipt defers to the framework: the Cancel All
Documents flow cancels the invoice first and then the receipt, and a direct
cancel is still rejected by Frappe's linked-document check.

Add a regression test that a direct cancel of a receipt with a submitted
invoice is rejected and rolls back, leaving no stray stock or GL entries.

(cherry picked from commit cfe18e8427)

# Conflicts:
#	erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
#	erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-30 12:41:53 +05:30
mergify[bot]
94d63ebb49 fix(stock): value batched packed-item returns from the original bundle (backport #57327) (#57510)
fix(stock): value batched packed-item returns from the original bundle  (#57327)

* fix(stock): value batched packed-item returns from the original bundle

when a return delivery note or sales invoice bundle is built via the
use_serial_batch_fields / sle-driven path, its voucher_detail_no keeps the
packed item instead of being remapped to the parent dn/si item. the return
valuation lookup then misses and the bundle values at zero, so the sle
stock_value_difference stays wrong even after a repost.

resolve the original dn/si item via the packed item's parent_detail_docname
when the direct lookup fails, so the return values from the original outward
bundle on both submit and repost.

* test(stock): cover batched packed-item return valuation on repost

(cherry picked from commit d37e905322)

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-07-30 07:02:41 +00:00
9 changed files with 190 additions and 26 deletions

View File

@@ -1239,7 +1239,7 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
return {
"company": purchase_doc.company,
"purchase_date": purchase_doc.get("posting_date"),
"gross_purchase_amount": flt(first_item.base_net_amount),
"gross_purchase_amount": flt(first_item.valuation_rate) * flt(first_item.qty),
"asset_quantity": first_item.qty,
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
"asset_location": first_item.get("asset_location"),

View File

@@ -446,3 +446,4 @@ erpnext.patches.v16_0.access_control_for_project_users
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
erpnext.patches.v15_0.fix_titles
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root

View File

@@ -0,0 +1,23 @@
import frappe
from frappe.utils.nestedset import get_root_of
SEEDED_ROOT = "All Item Groups"
def execute():
"""Collapse the "All Item Groups" node seeded under a pre-existing root.
Setup seeding always inserted "All Item Groups" as a parentless group. On a
site where another app had already created the root (under a translated
name), it was re-parented instead, leaving a second group-root holding the
standard Item Groups.
"""
root = get_root_of("Item Group")
if not root or root == SEEDED_ROOT:
return
seeded = frappe.db.get_value("Item Group", SEEDED_ROOT, ["parent_item_group", "is_group"], as_dict=True)
if not seeded or not seeded.is_group or seeded.parent_item_group != root:
return
frappe.rename_doc("Item Group", SEEDED_ROOT, root, merge=True, show_alert=False)

View File

@@ -16,6 +16,8 @@ from frappe.utils.nestedset import (
test_records = frappe.get_test_records("Item Group")
TRANSLATED_ROOT = "Todos os Grupos de Itens"
class TestItem(unittest.TestCase):
def test_basic_tree(self, records=None):
@@ -234,3 +236,46 @@ class TestItem(unittest.TestCase):
"_Test Item Group B - 3",
merge=True,
)
def test_patch_merges_seeded_root_into_existing_root(self):
from erpnext.patches.v16_0.merge_seeded_item_group_root import execute
self.nest_root_under(TRANSLATED_ROOT)
self.assertEqual(
frappe.db.get_value("Item Group", "All Item Groups", "parent_item_group"), TRANSLATED_ROOT
)
execute()
self.assertFalse(frappe.db.exists("Item Group", "All Item Groups"))
self.assertEqual(self.get_root_names(), [TRANSLATED_ROOT])
self.assertEqual(
frappe.db.get_value("Item Group", "_Test Item Group B", "parent_item_group"), TRANSLATED_ROOT
)
self.test_basic_tree()
# restore the original root name for the tests that follow
frappe.rename_doc("Item Group", TRANSLATED_ROOT, "All Item Groups")
self.assertEqual(self.get_root_names(), ["All Item Groups"])
self.test_basic_tree()
def nest_root_under(self, new_root):
"""Recreate the tree left behind by seeding a root under a pre-existing one."""
frappe.get_doc(
{
"doctype": "Item Group",
"item_group_name": new_root,
"is_group": 1,
"parent_item_group": "All Item Groups",
}
).insert()
ig = frappe.qb.DocType("Item Group")
frappe.qb.update(ig).set(ig.parent_item_group, "").where(ig.name == new_root).run()
frappe.qb.update(ig).set(ig.parent_item_group, new_root).where(ig.name == "All Item Groups").run()
rebuild_tree("Item Group", "parent_item_group")
def get_root_names(self):
return frappe.db.sql_list(
"""select name from `tabItem Group` where ifnull(parent_item_group, '')=''"""
)

View File

@@ -13,6 +13,7 @@ from frappe.desk.doctype.global_search_settings.global_search_settings import (
)
from frappe.desk.page.setup_wizard.setup_wizard import make_records
from frappe.utils import cstr, getdate
from frappe.utils.nestedset import get_root_of
from erpnext.accounts.doctype.account.account import RootNotEditable
from erpnext.regional.address_template.setup import set_up_address_templates
@@ -24,46 +25,48 @@ def read_lines(filename: str) -> list[str]:
def install(country=None):
root_item_group = get_root_of("Item Group") or _("All Item Groups")
records = [
# ensure at least an empty Address Template exists for this Country
{"doctype": "Address Template", "country": country},
# item group
{
"doctype": "Item Group",
"item_group_name": _("All Item Groups"),
"item_group_name": root_item_group,
"is_group": 1,
"parent_item_group": "",
"__condition": lambda: not frappe.db.exists("Item Group", root_item_group),
},
{
"doctype": "Item Group",
"item_group_name": _("Products"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
"show_in_website": 1,
},
{
"doctype": "Item Group",
"item_group_name": _("Raw Material"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
},
{
"doctype": "Item Group",
"item_group_name": _("Services"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
},
{
"doctype": "Item Group",
"item_group_name": _("Sub Assemblies"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
},
{
"doctype": "Item Group",
"item_group_name": _("Consumable"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
},
# Stock Entry Type
{

View File

@@ -706,6 +706,76 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(gle_warehouse_amount, 1400)
def test_return_bundle_voucher_detail_no_as_packed_item(self):
"""Return bundle whose voucher_detail_no is the Packed Item (SLE-driven path) must still value on repost."""
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
warehouse = "_Test Warehouse - _TC"
packed_item = make_item(
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BATCH-DN-RET-VDN-.#####",
}
).name
bundle_item = make_item(properties={"is_stock_item": 0, "is_sales_item": 1}).name
make_product_bundle(bundle_item, [packed_item], qty=20)
make_stock_entry(item_code=packed_item, target=warehouse, qty=60, basic_rate=35)
dn = create_delivery_note(item_code=bundle_item, warehouse=warehouse, qty=3)
return_dn = make_sales_return(dn.name)
return_dn.items[0].qty = -2
return_dn.submit()
return_dn.reload()
packed_row = return_dn.packed_items[0]
bundle = frappe.get_doc("Serial and Batch Bundle", packed_row.serial_and_batch_bundle)
# Reproduce the reported state: bundle points at the Packed Item (not the DN Item), valuation at 0.
bundle.db_set("voucher_detail_no", packed_row.name)
bundle.db_set({"avg_rate": 0, "total_amount": 0})
for entry in bundle.entries:
entry.db_set({"incoming_rate": 0, "stock_value_difference": 0})
packed_row.db_set("incoming_rate", 0)
frappe.db.set_value(
"Stock Ledger Entry",
{
"voucher_type": "Delivery Note",
"voucher_no": return_dn.name,
"item_code": packed_item,
"is_cancelled": 0,
},
{"incoming_rate": 0, "stock_value_difference": 0},
)
frappe.get_doc(
doctype="Repost Item Valuation",
based_on="Transaction",
voucher_type="Delivery Note",
voucher_no=return_dn.name,
posting_date=return_dn.posting_date,
posting_time=return_dn.posting_time,
).submit()
bundle.reload()
self.assertEqual(flt(bundle.avg_rate), 35)
incoming_rate, stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Delivery Note",
"voucher_no": return_dn.name,
"item_code": packed_item,
"is_cancelled": 0,
},
["incoming_rate", "stock_value_difference"],
)
self.assertEqual(flt(incoming_rate), 35)
self.assertEqual(flt(stock_value_difference), 1400)
def test_bin_details_of_packed_item(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -404,29 +404,10 @@ class PurchaseReceipt(BuyingController):
self.set_consumed_qty_in_subcontract_order()
self.reserve_stock_for_sales_order()
def check_next_docstatus(self):
submit_rv = frappe.db.sql(
"""select t1.name
from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
(self.name),
)
if submit_rv:
frappe.throw(_("Purchase Invoice {0} is already submitted").format(self.submit_rv[0][0]))
def on_cancel(self):
super().on_cancel()
self.check_on_hold_or_closed_status()
# Check if Purchase Invoice has been submitted against current Purchase Order
submitted = frappe.db.sql(
"""select t1.name
from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
self.name,
)
if submitted:
frappe.throw(_("Purchase Invoice {0} is already submitted").format(submitted[0][0]))
self.update_prevdoc_status()
self.update_billing_status()

View File

@@ -5446,6 +5446,32 @@ class TestPurchaseReceipt(FrappeTestCase):
srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account)
self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2)
def test_cancel_blocked_by_submitted_invoice_rolls_back(self):
"""A submitted Purchase Invoice must block cancelling its Purchase Receipt. Frappe's backlink
check rejects the cancel only after on_cancel has run stock, GL, and status work, so the whole
transaction has to roll back: the receipt stays submitted with no leaked ledger entries."""
pr = make_purchase_receipt()
pi = make_purchase_invoice(pr.name)
pi.insert()
pi.submit()
pr.reload()
status_before = pr.status
sle_before = frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name})
gle_before = frappe.db.count("GL Entry", {"voucher_no": pr.name})
frappe.db.savepoint("before_blocked_cancel")
with self.assertRaises(frappe.LinkExistsError) as cm:
pr.cancel()
self.assertIn(pi.name, str(cm.exception))
frappe.db.rollback(save_point="before_blocked_cancel") # mimic the request-level rollback
pr.reload()
self.assertEqual(pr.docstatus, 1)
self.assertEqual(pr.status, status_before)
self.assertEqual(frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name}), sle_before)
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pr.name}), gle_before)
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -505,6 +505,11 @@ class SerialandBatchBundle(Document):
self.child_table, self.voucher_detail_no, field
)
if not return_against_voucher_detail_no and self.voucher_type in ("Delivery Note", "Sales Invoice"):
# Bundles built via the use_serial_batch_fields / SLE-driven path keep the Packed Item
# as voucher_detail_no (not remapped to the DN/SI Item), so the lookup above misses.
return_against_voucher_detail_no = self.get_return_against_packed_item(field)
filters = [
["Serial and Batch Bundle", "voucher_no", "=", return_against],
["Serial and Batch Entry", "docstatus", "=", 1],
@@ -548,6 +553,16 @@ class SerialandBatchBundle(Document):
return valuation_details
def get_return_against_packed_item(self, field):
"""Resolve the original DN/SI Item when a return bundle's voucher_detail_no is the Packed Item."""
parent_detail_docname = frappe.db.get_value(
"Packed Item", self.voucher_detail_no, "parent_detail_docname"
)
if not parent_detail_docname:
return
return frappe.db.get_value(self.child_table, parent_detail_docname, field)
def get_legacy_valuation_rate_for_return_entry(
self, return_against, return_against_voucher_detail_no, return_warehouse=None
):