Merge branch 'version-15-hotfix' into mergify/bp/version-15-hotfix/pr-58351

This commit is contained in:
Aarol D'Souza
2026-08-27 15:42:27 +05:30
committed by GitHub
11 changed files with 544 additions and 20 deletions

View File

@@ -54,7 +54,7 @@ class PeriodClosingVoucher(AccountsController):
if for_cancellation and is_immutable_ledger_enabled():
posting_date = getdate()
check_freezing_date(posting_date, self.company)
check_freezing_date(posting_date)
def validate_start_and_end_date(self):
self.fy_start_date, self.fy_end_date = frappe.db.get_value(

View File

@@ -1395,6 +1395,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1413,6 +1414,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1431,6 +1433,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1449,6 +1452,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1467,6 +1471,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1485,6 +1490,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1507,6 +1513,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1525,6 +1532,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1543,6 +1551,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1561,6 +1570,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1579,6 +1589,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1597,6 +1608,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1628,6 +1640,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1637,6 +1650,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1646,6 +1660,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1655,6 +1670,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1664,6 +1680,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1673,6 +1690,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1682,6 +1700,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1691,6 +1710,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1700,6 +1720,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1709,6 +1730,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1718,6 +1740,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1727,6 +1750,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1735,6 +1759,7 @@
"account_number": "1433",
"root_type": "Asset"
},
"not_applicable": 1,
"tax_rate": 0.00
}
]
@@ -2158,6 +2183,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2176,6 +2202,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2194,6 +2221,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2212,6 +2240,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2230,6 +2259,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2248,6 +2278,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2270,6 +2301,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2288,6 +2320,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2306,6 +2339,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2324,6 +2358,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2342,6 +2377,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2360,6 +2396,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2391,6 +2428,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2400,6 +2438,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2409,6 +2448,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2418,6 +2458,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2427,6 +2468,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2436,6 +2478,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2445,6 +2488,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2454,6 +2498,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2463,6 +2508,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2472,6 +2518,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2481,6 +2528,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2490,6 +2538,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2498,6 +2547,7 @@
"account_number": "1588",
"root_type": "Asset"
},
"not_applicable": 1,
"tax_rate": 0.00
}
]
@@ -2921,6 +2971,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2939,6 +2990,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2957,6 +3009,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2975,6 +3028,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2993,6 +3047,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3011,6 +3066,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3033,6 +3089,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3051,6 +3108,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3069,7 +3127,8 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"tax_rate": 19.00
"not_applicable": 1,
"tax_rate": 0.00
},
{
"tax_type": {
@@ -3087,6 +3146,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3105,6 +3165,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3123,6 +3184,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3154,6 +3216,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3163,6 +3226,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3172,6 +3236,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3181,6 +3246,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3190,6 +3256,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3199,6 +3266,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3208,6 +3276,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3217,6 +3286,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3226,6 +3296,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3235,6 +3306,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3244,6 +3316,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3253,6 +3326,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3261,6 +3335,7 @@
"account_number": "1550",
"root_type": "Asset"
},
"not_applicable": 1,
"tax_rate": 0.00
}
]
@@ -3653,6 +3728,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3669,6 +3745,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3685,6 +3762,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3701,6 +3779,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3717,6 +3796,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3733,6 +3813,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3753,6 +3834,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3769,6 +3851,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3785,6 +3868,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3801,6 +3885,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3817,6 +3902,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3833,6 +3919,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3861,6 +3948,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3869,6 +3957,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3877,6 +3966,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3885,6 +3975,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3893,6 +3984,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3901,6 +3993,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3909,6 +4002,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3917,6 +4011,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3925,6 +4020,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3933,6 +4029,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3941,6 +4038,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3949,6 +4047,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3956,6 +4055,7 @@
"account_name": "Entstandene Einfuhrumsatzsteuer",
"root_type": "Asset"
},
"not_applicable": 1,
"tax_rate": 0.00
}
]

View File

@@ -94,6 +94,22 @@ class MaterialRequest(BuyingController):
def check_if_already_pulled(self):
pass
def validate_with_previous_doc(self):
super().validate_with_previous_doc(
{
"Sales Order": {
"ref_dn_field": "sales_order",
"compare_fields": [["company", "="]],
},
"Sales Order Item": {
"ref_dn_field": "sales_order_item",
"compare_fields": [["item_code", "="], ["uom", "="], ["conversion_factor", "="]],
"is_child_table": True,
"allow_duplicate_prev_row_id": True,
},
}
)
def validate_qty_against_so(self):
so_items = {} # Format --> {'SO/00001': {'Item/001': 120, 'Item/002': 24}}
for d in self.get("items"):
@@ -136,6 +152,7 @@ class MaterialRequest(BuyingController):
self.validate_schedule_date()
self.check_for_on_hold_or_closed_status("Sales Order", "sales_order")
self.validate_with_previous_doc()
self.validate_uom_is_integer("uom", "qty")
self.validate_material_request_type()

View File

@@ -919,6 +919,18 @@ class TestMaterialRequest(FrappeTestCase):
self.assertRaises(OverAllowanceError, mr.submit)
def test_item_change_on_sales_order_row_is_blocked(self):
from erpnext.selling.doctype.sales_order.sales_order import make_material_request
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
other_item = create_item("_Test MR Item Swap").name
so = make_sales_order()
mr = make_material_request(so.name)
mr.material_request_type = "Purchase"
# swapping the fetched item would leave a stale link to the SO row
mr.items[0].item_code = other_item
self.assertRaises(frappe.ValidationError, mr.insert)
def test_pending_qty_in_pick_list(self):
"""Test for pick list mapped doc qty from partially received Material Request Transfer"""
import json

View File

@@ -1508,6 +1508,7 @@ def add_product_bundles_to_delivery_note(
@frappe.whitelist()
def create_stock_entry(pick_list: str | dict):
pick_list = frappe.get_doc(frappe.parse_json(pick_list))
pick_list.check_permission("read")
validate_item_locations(pick_list)
stock_entry = frappe.new_doc("Stock Entry")

View File

@@ -1078,6 +1078,58 @@ class TestPickList(FrappeTestCase):
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
self.assertEqual(pick_list.status, "Partially Transferred")
def test_get_items_keeps_pick_list_rows_on_stock_entry(self):
"""Entering fg_completed_qty on a Stock Entry mapped from a Pick List triggers get_items();
it must not refetch from the BOM, or the pick_list_item links transferred_qty rides on are
lost and the Pick List stays Open with every row offered again."""
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.work_order import (
create_pick_list as pick_list_for_wo,
)
from erpnext.manufacturing.doctype.work_order.work_order import make_work_order
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
source_warehouse = create_warehouse("_Test Partial Transfer Source")
wip_warehouse = create_warehouse("_Test Partial Transfer WIP", company="_Test Company")
fg_warehouse = create_warehouse("_Test Partial Transfer FG", company="_Test Company")
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1}).name
bom = make_bom(item=fg_item, rate=100, raw_materials=[rm_item])
make_stock_entry(item=rm_item, to_warehouse=source_warehouse, qty=100)
wo = make_work_order(item=fg_item, qty=10, bom_no=bom.name, company="_Test Company")
wo.required_items[0].source_warehouse = source_warehouse
wo.wip_warehouse = wip_warehouse
wo.fg_warehouse = fg_warehouse
wo.submit()
pick_list = pick_list_for_wo(wo.name, for_qty=wo.qty)
pick_list.save().submit()
self.assertEqual(pick_list.status, "Open")
se = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
self.assertTrue(all(row.pick_list_item for row in se.items))
self.assertEqual(se.fg_completed_qty, 0)
se.fg_completed_qty = 4
se.get_items()
self.assertEqual(len(se.items), len(pick_list.locations))
self.assertTrue(all(row.pick_list_item for row in se.items))
se.fg_completed_qty = 0
for row in se.items:
row.qty = 4
se.save().submit()
self.assertEqual(se.fg_completed_qty, 0)
pick_list.reload()
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
self.assertEqual(pick_list.status, "Partially Transferred")
next_se = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
self.assertEqual(len(next_se.items), 1)
self.assertEqual(next_se.items[0].qty, 6)
def test_create_second_delivery_note_with_fully_delivered_location(self):
# When one pick list item is fully delivered by the first Delivery Note
# and another item is still pending, creating a second Delivery Note from

View File

@@ -181,6 +181,13 @@ frappe.ui.form.on("Stock Entry", {
if (!check_should_not_attach_bom_items(frm.doc.bom_no)) {
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
}
if (frm.doc.pick_list) {
frm.set_df_property("get_items", "hidden", 1);
if (!frm.doc.job_card) {
frm.set_df_property("fg_completed_qty", "read_only", 1);
}
}
},
setup_quality_inspection: function (frm) {
@@ -1356,10 +1363,16 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
) {
frappe.model.remove_from_locals("Work Order", this.frm.doc.work_order);
}
if (this.frm.doc.pick_list) {
frappe.model.remove_from_locals("Pick List", this.frm.doc.pick_list);
}
}
fg_completed_qty() {
this.get_items();
if (!this.frm.doc.pick_list) {
this.get_items();
}
}
get_items() {

View File

@@ -2576,6 +2576,9 @@ class StockEntry(StockController):
@frappe.whitelist()
def get_items(self):
if self.pick_list:
return
self.set("items", [])
self.validate_work_order()

View File

@@ -7,6 +7,7 @@ import json
import frappe
from frappe import _, throw
from frappe.model import child_table_fields, default_fields
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.model.utils import get_fetch_values
from frappe.query_builder.functions import IfNull, Sum
@@ -34,11 +35,34 @@ purchase_doctypes = [
"Purchase Invoice",
]
maintain_same_rate_source_fields = {
"Purchase Order": {"supplier_quotation_item": "Supplier Quotation Item"},
"Purchase Receipt": {"purchase_order_item": "Purchase Order Item"},
"Purchase Invoice": {"po_detail": "Purchase Order Item", "pr_detail": "Purchase Receipt Item"},
"Sales Order": {"quotation_item": "Quotation Item"},
"Delivery Note": {"so_detail": "Sales Order Item", "si_detail": "Sales Invoice Item"},
"Sales Invoice": {"so_detail": "Sales Order Item", "dn_detail": "Delivery Note Item"},
}
LOCKED_RATE_FIELDS = [
"price_list_rate",
"rate",
"discount_percentage",
"discount_amount",
"margin_type",
"margin_rate_or_amount",
]
NOT_APPLICABLE_TAX = "N/A"
@frappe.whitelist()
def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=True):
def get_item_details(
args: dict | str,
doc: Document | dict | str | None = None,
for_validate: bool | str = False,
overwrite_warehouse: bool | str = True,
):
"""
args = {
"item_code": "",
@@ -100,16 +124,20 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru
if args.get("doctype") in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]:
args.customer = None
out.update(get_price_list_rate(args, item))
source_row = get_rate_locked_source_row(args, doc)
if source_row:
lock_source_rate(out, source_row)
else:
out.update(get_price_list_rate(args, item))
if (
not out.price_list_rate
and args.transaction_type == "selling"
and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list")
):
fallback_args = args.copy()
fallback_args.price_list = frappe.get_single_value("Selling Settings", "selling_price_list")
out.update(get_price_list_rate(fallback_args, item))
if (
not out.price_list_rate
and args.transaction_type == "selling"
and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list")
):
fallback_args = args.copy()
fallback_args.price_list = frappe.get_single_value("Selling Settings", "selling_price_list")
out.update(get_price_list_rate(fallback_args, item))
args.customer = current_customer
@@ -124,9 +152,8 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru
if args.get(key) is None:
args[key] = value
data = get_pricing_rule_for_item(args, doc=doc, for_validate=for_validate)
out.update(data)
if not source_row:
out.update(get_pricing_rule_for_item(args, doc=doc, for_validate=for_validate))
if (
frappe.db.get_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward")
@@ -156,6 +183,52 @@ def remove_standard_fields(details):
return details
def get_rate_locked_source_row(args, doc):
"""Reads the source row from the DB, not the mutable target row, so an unsaved edit can't override the locked rate."""
if isinstance(doc, str):
doc = json.loads(doc)
source_fields = maintain_same_rate_source_fields.get(args.parenttype or args.doctype)
if not source_fields or not doc or args.get("is_return") or not maintain_same_rate_enabled(args):
return None
row = next((d for d in doc.get("items") or [] if d.get("name") == args.child_docname), None)
if not row:
return None
for link_field, source_doctype in source_fields.items():
if source_name := row.get(link_field):
# don't leak another document's pricing to a caller without read access
source = frappe.db.get_value(
source_doctype, source_name, [*LOCKED_RATE_FIELDS, "parent", "parenttype"], as_dict=True
)
if source and frappe.has_permission(source.parenttype, doc=source.parent):
return source
return None
return None
def maintain_same_rate_enabled(transaction_args):
if (transaction_args.parenttype or transaction_args.doctype) in purchase_doctypes:
if transaction_args.get("is_internal_supplier"):
return False
return bool(cint(frappe.get_cached_value("Buying Settings", "None", "maintain_same_rate")))
if transaction_args.get("is_internal_customer"):
return False
return bool(cint(frappe.get_cached_value("Selling Settings", "None", "maintain_same_sales_rate")))
def lock_source_rate(out, source_row):
"""Copies the full pricing block so a manual discount or margin on the source row survives."""
out.price_list_rate = flt(source_row.get("price_list_rate")) or flt(source_row.get("rate"))
out.rate = flt(source_row.get("rate"))
out.discount_percentage = flt(source_row.get("discount_percentage"))
out.discount_amount = flt(source_row.get("discount_amount"))
out.margin_type = source_row.get("margin_type")
out.margin_rate_or_amount = flt(source_row.get("margin_rate_or_amount"))
def set_valuation_rate(out, args):
if frappe.db.exists("Product Bundle", {"name": args.item_code, "disabled": 0}, cache=True):
valuation_rate = 0.0
@@ -1522,13 +1595,21 @@ def apply_price_list(args, as_doc=False, doc=None):
def apply_price_list_on_item(args, doc=None):
item_doc = frappe.db.get_value("Item", args.item_code, ["name", "variant_of"], as_dict=1)
item_details = get_price_list_rate(args, item_doc)
source_row = get_rate_locked_source_row(args, doc)
if source_row:
item_details = frappe._dict()
lock_source_rate(item_details, source_row)
else:
item_details = get_price_list_rate(args, item_doc)
args.conversion_factor = flt(args.conversion_factor) or get_conversion_factor(
args.item_code, args.uom
).get("conversion_factor", 1)
args.stock_qty = flt(args.qty) * flt(args.conversion_factor)
item_details.update(get_pricing_rule_for_item(args, doc=doc))
if not source_row:
item_details.update(get_pricing_rule_for_item(args, doc=doc))
return item_details

View File

@@ -9,9 +9,9 @@
"idx": 0,
"is_standard": "Yes",
"json": "{}",
"letter_head": "Test",
"letter_head": null,
"letterhead": null,
"modified": "2025-02-03 15:39:47.613040",
"modified": "2026-08-26 14:39:19.102191",
"modified_by": "Administrator",
"module": "Stock",
"name": "Incorrect Serial and Batch Bundle",

View File

@@ -177,3 +177,248 @@ class TestGetItemDetail(FrappeTestCase):
dn.save()
self.assertEqual(dn.items[0].batch_no, "BATCH01")
self.assertEqual(dn.items[0].rate, 50)
def test_maintain_same_rate_keeps_source_rate_on_refetch(self):
"""#57436: with "maintain same rate" on, re-fetching a PR row mapped from a
PO must keep the PO rate instead of pulling a newer, higher Item Price.
The rate is validated on save, so it can never persist changed; assert the
fetched rate directly to prove the newer Item Price is never picked up.
"""
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.stock.doctype.item.test_item import make_item
def set_maintain_same_rate(value):
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", value)
frappe.clear_cache(doctype="Buying Settings")
set_maintain_same_rate(1)
item_code = make_item(properties={"is_stock_item": 1}).name
po = create_purchase_order(item_code=item_code, qty=1, rate=100)
# The PO may auto-insert an Item Price at 100; bump it to the newer, higher rate.
item_price = frappe.db.get_value(
"Item Price", {"item_code": item_code, "price_list": "Standard Buying"}
)
if item_price:
frappe.db.set_value("Item Price", item_price, "price_list_rate", 120)
else:
frappe.get_doc(
{
"doctype": "Item Price",
"price_list": "Standard Buying",
"item_code": item_code,
"price_list_rate": 120,
}
).insert()
pr = make_purchase_receipt(po.name)
pr.insert()
def fetch_price_list_rate():
args = frappe._dict(
{
"item_code": item_code,
"doctype": "Purchase Receipt",
"name": pr.name,
"company": pr.company,
"supplier": pr.supplier,
"currency": pr.currency,
"conversion_rate": 1.0,
"price_list": "Standard Buying",
"price_list_currency": pr.currency,
"plc_conversion_rate": 1.0,
"warehouse": pr.items[0].warehouse,
"uom": pr.items[0].uom,
"stock_uom": pr.items[0].stock_uom,
"qty": pr.items[0].qty,
"child_doctype": pr.items[0].doctype,
"child_docname": pr.items[0].name,
"is_return": 0,
"is_internal_supplier": 0,
"ignore_pricing_rule": 1,
}
)
return get_item_details(args, pr).get("price_list_rate")
# Rate stays at the PO rate; the newer Item Price (120) is not fetched.
self.assertEqual(fetch_price_list_rate(), 100)
# Control: without the setting the newer Item Price would be fetched.
set_maintain_same_rate(0)
self.assertEqual(fetch_price_list_rate(), 120)
def test_apply_price_list_keeps_source_rate_when_maintain_same_rate(self):
"""#57436: the bulk apply_price_list path (price list / party / conversion rate
change) must also keep the source rate on mapped rows, not just re-fetch of a
single row. Here a PR row carries its PO rate (175) while the current price list
rate is 100; the bulk apply must keep 175.
"""
from frappe.utils import flt, nowdate
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.stock.get_item_details import apply_price_list
item_code = "_Test Item"
price_list = "_Test Buying Price List"
original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate")
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
frappe.clear_cache(doctype="Buying Settings")
try:
po = create_purchase_order(item_code=item_code, rate=175, qty=1)
row_name = "pr-row-1"
pr_doc = {
"doctype": "Purchase Receipt",
"items": [
{
"name": row_name,
"item_code": item_code,
"purchase_order_item": po.items[0].name,
"price_list_rate": 175,
"rate": 175,
}
],
}
args = frappe._dict(
doctype="Purchase Receipt",
supplier=po.supplier,
company=po.company,
currency=po.currency,
conversion_rate=1.0,
price_list=price_list,
plc_conversion_rate=1.0,
transaction_date=nowdate(),
items=[
frappe._dict(
doctype="Purchase Receipt Item",
parenttype="Purchase Receipt",
item_code=item_code,
child_docname=row_name,
qty=1,
uom=po.items[0].uom,
stock_uom=po.items[0].stock_uom,
conversion_factor=1.0,
)
],
)
result = apply_price_list(args, doc=pr_doc)
self.assertEqual(flt(result["children"][0].get("price_list_rate")), 175)
finally:
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original)
frappe.clear_cache(doctype="Buying Settings")
def test_maintain_same_rate_keeps_source_discount_on_refetch(self):
"""A mapped source row with a discount has rate != price_list_rate. Re-fetch must
return the source's rate and discount, not just the pre-discount price, or the
recomputed rate diverges from the reference and fails maintain-same-rate on save.
"""
from frappe.utils import flt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
item_code = "_Test Item"
price_list = "_Test Buying Price List"
original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate")
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
frappe.clear_cache(doctype="Buying Settings")
try:
# source PO carries the discount: list rate 100, 10% off, effective rate 90
frappe.flags.dont_fetch_price_list_rate = True
po = create_purchase_order(item_code=item_code, qty=1, do_not_save=True)
po.buying_price_list = price_list
po.items[0].price_list_rate = 100
po.items[0].discount_percentage = 10
po.items[0].rate = 90
po.insert()
po.submit()
frappe.flags.dont_fetch_price_list_rate = False
row_name = "pr-row-1"
pr_doc = {
"doctype": "Purchase Receipt",
"items": [
{"name": row_name, "item_code": item_code, "purchase_order_item": po.items[0].name}
],
}
args = frappe._dict(
item_code=item_code,
doctype="Purchase Receipt",
company=po.company,
supplier=po.supplier,
currency=po.currency,
conversion_rate=1.0,
price_list=price_list,
price_list_currency=po.currency,
plc_conversion_rate=1.0,
warehouse="_Test Warehouse - _TC",
uom=po.items[0].uom,
stock_uom=po.items[0].stock_uom,
qty=1,
child_docname=row_name,
is_return=0,
is_internal_supplier=0,
ignore_pricing_rule=1,
)
out = get_item_details(args, pr_doc)
self.assertEqual(flt(out.get("price_list_rate")), 100)
self.assertEqual(flt(out.get("rate")), 90)
self.assertEqual(flt(out.get("discount_percentage")), 10)
finally:
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original)
frappe.clear_cache(doctype="Buying Settings")
frappe.flags.dont_fetch_price_list_rate = False
def test_rate_lock_source_lookup_checks_permission(self):
"""The lock reads source pricing via a direct DB read, so it must not disclose a
source document's pricing to a caller who cannot read that document.
"""
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.stock.get_item_details import get_rate_locked_source_row
original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate")
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
frappe.clear_cache(doctype="Buying Settings")
role, email = "_Test Role Without PO Access", "_test_rate_lock_probe@example.com"
try:
po = create_purchase_order(item_code="_Test Item", qty=1, rate=90)
pr_doc = {
"doctype": "Purchase Receipt",
"items": [{"name": "r1", "item_code": "_Test Item", "purchase_order_item": po.items[0].name}],
}
args = frappe._dict(doctype="Purchase Receipt", child_docname="r1")
# an authorized caller receives the source row
self.assertIsNotNone(get_rate_locked_source_row(args.copy(), dict(pr_doc)))
if not frappe.db.exists("Role", role):
frappe.get_doc({"doctype": "Role", "role_name": role, "desk_access": 1}).insert(
ignore_permissions=True
)
if not frappe.db.exists("User", email):
frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": "Probe",
"send_welcome_email": 0,
"roles": [{"role": role}],
}
).insert(ignore_permissions=True)
frappe.set_user(email)
# a caller who cannot read the Purchase Order gets nothing
self.assertIsNone(get_rate_locked_source_row(args.copy(), dict(pr_doc)))
finally:
frappe.set_user("Administrator")
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original)
frappe.clear_cache(doctype="Buying Settings")