fix: prevent leakage of party-derived fields in cross doctype transactions (backport #55336) (#55578)

Co-authored-by: Nabin Hait <nabinhait@gmail.com>
Co-authored-by: Lakshit Jain <ljain112@gmail.com>
fix: prevent leakage of party-derived fields in cross doctype transactions (#55336)
This commit is contained in:
mergify[bot]
2026-06-09 13:23:49 +00:00
committed by GitHub
parent a5c23a3d16
commit 0e64acb0fa
7 changed files with 133 additions and 36 deletions

View File

@@ -23,7 +23,12 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
get_party_tax_withholding_details, get_party_tax_withholding_details,
) )
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
from erpnext.accounts.party import get_due_date, get_party_account, get_party_details from erpnext.accounts.party import (
CROSS_PARTY_FIELD_NO_MAP,
get_due_date,
get_party_account,
get_party_details,
)
from erpnext.accounts.utils import ( from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal, cancel_exchange_gain_loss_journal,
get_account_currency, get_account_currency,
@@ -2559,7 +2564,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
"doctype": target_doctype, "doctype": target_doctype,
"postprocess": update_details, "postprocess": update_details,
"set_target_warehouse": "set_from_warehouse", "set_target_warehouse": "set_from_warehouse",
"field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address", "cost_center"], "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse", "cost_center"],
}, },
doctype + " Item": item_field_map, doctype + " Item": item_field_map,
}, },

View File

@@ -2751,6 +2751,34 @@ class TestSalesInvoice(FrappeTestCase):
): ):
make_inter_company_transaction("Sales Invoice", si.name) make_inter_company_transaction("Sales Invoice", si.name)
def test_inter_company_transaction_does_not_inherit_party_fields(self):
"""
Party-derived fields on SI (from Customer) must not leak into the mapped PI.
"""
si = create_sales_invoice(
company="Wind Power LLC",
customer="_Test Internal Customer",
debit_to="Debtors - WP",
warehouse="Stores - WP",
income_account="Sales - WP",
expense_account="Cost of Goods Sold - WP",
cost_center="Main - WP",
currency="USD",
do_not_save=1,
)
si.selling_price_list = "_Test Price List Rest of the World"
si.tax_category = "_Test Tax Category 1"
si.language = "ar"
si.payment_terms_template = "_Test Payment Term Template"
si.submit()
pi = make_inter_company_transaction("Sales Invoice", si.name)
supplier = frappe.get_doc("Supplier", "_Test Internal Supplier")
self.assertEqual(pi.tax_category or None, supplier.tax_category or None)
self.assertEqual(pi.language or None, supplier.language or None)
self.assertEqual(pi.payment_terms_template or None, supplier.payment_terms or None)
def test_inter_company_transaction_without_default_warehouse(self): def test_inter_company_transaction_without_default_warehouse(self):
"Check mapping (expense account) of inter company SI to PI in absence of default warehouse." "Check mapping (expense account) of inter company SI to PI in absence of default warehouse."
# setup # setup

View File

@@ -48,6 +48,25 @@ SALES_TRANSACTION_TYPES = {
} }
TRANSACTION_TYPES = PURCHASE_TRANSACTION_TYPES | SALES_TRANSACTION_TYPES TRANSACTION_TYPES = PURCHASE_TRANSACTION_TYPES | SALES_TRANSACTION_TYPES
# Party-derived fields that must NOT be auto-copied by `get_mapped_doc` when the
# source and target documents belong to different parties (e.g. Sales Order →
# Purchase Order or inter-company Sales Invoice → Purchase Invoice).
CROSS_PARTY_FIELD_NO_MAP = [
"tax_category",
"tax_id",
"tax_withholding_category",
"taxes_and_charges",
"address_display",
"contact_display",
"contact_mobile",
"contact_email",
"contact_person",
"shipping_address",
"dispatch_address",
"payment_terms_template",
"language",
]
class DuplicatePartyAccountError(frappe.ValidationError): class DuplicatePartyAccountError(frappe.ValidationError):
pass pass

View File

@@ -10,6 +10,7 @@ import frappe.utils
from frappe import _, qb from frappe import _, qb
from frappe.contacts.doctype.address.address import get_company_address from frappe.contacts.doctype.address.address import get_company_address
from frappe.desk.notifications import clear_doctype_notifications from frappe.desk.notifications import clear_doctype_notifications
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values from frappe.model.utils import get_fetch_values
from frappe.query_builder.functions import Sum from frappe.query_builder.functions import Sum
@@ -20,7 +21,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
update_linked_doc, update_linked_doc,
validate_inter_company_party, validate_inter_company_party,
) )
from erpnext.accounts.party import get_party_account from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account
from erpnext.controllers.selling_controller import SellingController from erpnext.controllers.selling_controller import SellingController
from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
validate_against_blanket_order, validate_against_blanket_order,
@@ -1332,7 +1333,9 @@ def get_events(start, end, filters=None):
@frappe.whitelist() @frappe.whitelist()
def make_purchase_order_for_default_supplier(source_name, selected_items=None, target_doc=None): def make_purchase_order_for_default_supplier(
source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None
):
"""Creates Purchase Order for each Supplier. Returns a list of doc objects.""" """Creates Purchase Order for each Supplier. Returns a list of doc objects."""
from erpnext.setup.utils import get_exchange_rate from erpnext.setup.utils import get_exchange_rate
@@ -1361,7 +1364,6 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
target.shipping_rule = "" target.shipping_rule = ""
target.tc_name = "" target.tc_name = ""
target.terms = "" target.terms = ""
target.payment_terms_template = ""
target.payment_schedule = [] target.payment_schedule = []
default_price_list = frappe.get_value("Supplier", supplier, "default_price_list") default_price_list = frappe.get_value("Supplier", supplier, "default_price_list")
@@ -1418,16 +1420,7 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
{ {
"Sales Order": { "Sales Order": {
"doctype": "Purchase Order", "doctype": "Purchase Order",
"field_no_map": [ "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP],
"address_display",
"contact_display",
"contact_mobile",
"contact_email",
"contact_person",
"taxes_and_charges",
"shipping_address",
"dispatch_address",
],
"validation": {"docstatus": ["=", 1]}, "validation": {"docstatus": ["=", 1]},
}, },
"Sales Order Item": { "Sales Order Item": {
@@ -1492,7 +1485,9 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
@frappe.whitelist() @frappe.whitelist()
def make_purchase_order(source_name, selected_items=None, target_doc=None): def make_purchase_order(
source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None
):
if not selected_items: if not selected_items:
return return
@@ -1520,7 +1515,6 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None):
target.shipping_rule = "" target.shipping_rule = ""
target.tc_name = "" target.tc_name = ""
target.terms = "" target.terms = ""
target.payment_terms_template = ""
target.payment_schedule = [] target.payment_schedule = []
if is_drop_ship_order(target): if is_drop_ship_order(target):
@@ -1559,16 +1553,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None):
{ {
"Sales Order": { "Sales Order": {
"doctype": "Purchase Order", "doctype": "Purchase Order",
"field_no_map": [ "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP],
"address_display",
"contact_display",
"contact_mobile",
"contact_email",
"contact_person",
"taxes_and_charges",
"shipping_address",
"dispatch_address",
],
"validation": {"docstatus": ["=", 1]}, "validation": {"docstatus": ["=", 1]},
}, },
"Sales Order Item": { "Sales Order Item": {

View File

@@ -24,6 +24,8 @@ from erpnext.selling.doctype.sales_order.sales_order import (
create_pick_list, create_pick_list,
make_delivery_note, make_delivery_note,
make_material_request, make_material_request,
make_purchase_order,
make_purchase_order_for_default_supplier,
make_raw_material_request, make_raw_material_request,
make_sales_invoice, make_sales_invoice,
make_work_orders, make_work_orders,
@@ -1364,8 +1366,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
Tests if the the Product Bundles in the Items table of Sales Orders are replaced with Tests if the the Product Bundles in the Items table of Sales Orders are replaced with
their child items(from the Packed Items table) on creating a Purchase Order from it. their child items(from the Packed Items table) on creating a Purchase Order from it.
""" """
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0})
make_item("_Test Bundle Item 1", {"is_stock_item": 1}) make_item("_Test Bundle Item 1", {"is_stock_item": 1})
make_item("_Test Bundle Item 2", {"is_stock_item": 1}) make_item("_Test Bundle Item 2", {"is_stock_item": 1})
@@ -1394,8 +1394,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
""" """
Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order
""" """
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0})
make_item("_Test Bundle Item 1", {"is_stock_item": 1}) make_item("_Test Bundle Item 1", {"is_stock_item": 1})
make_item("_Test Bundle Item 2", {"is_stock_item": 1}) make_item("_Test Bundle Item 2", {"is_stock_item": 1})
@@ -2419,8 +2417,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
self.assertRaises(frappe.ValidationError, so1.update_status, "Draft") self.assertRaises(frappe.ValidationError, so1.update_status, "Draft")
def test_item_tax_transfer_from_sales_to_purchase(self): def test_item_tax_transfer_from_sales_to_purchase(self):
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
item_tax = frappe.new_doc("Item Tax Template") item_tax = frappe.new_doc("Item Tax Template")
item_tax.title = "Test Item Tax Template" item_tax.title = "Test Item Tax Template"
item_tax.company = "_Test Company" item_tax.company = "_Test Company"
@@ -2521,6 +2517,33 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
self.assertFalse(so.per_billed) self.assertFalse(so.per_billed)
self.assertEqual(so.status, "To Deliver and Bill") self.assertEqual(so.status, "To Deliver and Bill")
def test_make_purchase_order_does_not_inherit_party_fields(self):
"""
Customer-derived fields must not leak from a drop-ship SO into the PO.
"""
so_items = [
{
"item_code": "_Test Item",
"warehouse": "",
"qty": 1,
"rate": 100,
"delivered_by_supplier": 1,
"supplier": "_Test Supplier",
}
]
so = make_sales_order(item_list=so_items, do_not_submit=True)
so.tax_category = "_Test Tax Category 1"
so.language = "ar"
so.payment_terms_template = "_Test Payment Term Template"
so.submit()
po = make_purchase_order_for_default_supplier(so.name, selected_items=so_items)[0]
supplier = frappe.get_doc("Supplier", "_Test Supplier")
self.assertEqual(po.tax_category or None, supplier.tax_category or None)
self.assertEqual(po.language or None, supplier.language or None)
self.assertEqual(po.payment_terms_template or None, supplier.payment_terms or None)
def test_pending_quantity_after_update_item_during_invoice_creation(self): def test_pending_quantity_after_update_item_during_invoice_creation(self):
so = make_sales_order(qty=30, rate=100) so = make_sales_order(qty=30, rate=100)

View File

@@ -15,7 +15,7 @@ from frappe.query_builder import DocType
from frappe.query_builder.functions import Abs, Sum from frappe.query_builder.functions import Abs, Sum
from frappe.utils import cint, flt from frappe.utils import cint, flt
from erpnext.accounts.party import 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.controllers.selling_controller import SellingController from erpnext.controllers.selling_controller import SellingController
from erpnext.stock.stock_ledger import validate_reserved_stock from erpnext.stock.stock_ledger import validate_reserved_stock
@@ -1405,8 +1405,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
doctype: { doctype: {
"doctype": target_doctype, "doctype": target_doctype,
"postprocess": update_details, "postprocess": update_details,
"field_no_map": ["taxes_and_charges", "set_warehouse"], "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse"],
"field_map": {"shipping_address_name": "shipping_address"},
}, },
doctype + " Item": { doctype + " Item": {
"doctype": target_doctype + " Item", "doctype": target_doctype + " Item",

View File

@@ -1050,6 +1050,44 @@ class TestPurchaseReceipt(FrappeTestCase):
pr.cancel() pr.cancel()
def test_inter_company_purchase_receipt_does_not_inherit_party_fields(self):
"""
Party-derived fields on DN (from Customer) must not leak into the mapped PR.
"""
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
customer = "_Test Internal Customer 2"
company = "_Test Company with perpetual inventory"
stock = make_purchase_receipt(warehouse="Stores - TCP1", company=company)
dn = create_delivery_note(
company=company,
customer=customer,
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=1,
rate=100,
warehouse="Stores - TCP1",
target_warehouse="Work In Progress - TCP1",
do_not_submit=True,
)
# Stamp customer-side party fields onto the DN
dn.tax_category = "_Test Tax Category 2"
dn.language = "ar"
dn.submit()
pr = make_inter_company_purchase_receipt(dn.name)
supplier = frappe.get_doc("Supplier", "_Test Internal Supplier 2")
self.assertEqual(pr.tax_category or None, supplier.tax_category or None)
self.assertEqual(pr.language or None, supplier.language or None)
dn.cancel()
stock.cancel()
def test_lcv_for_internal_transfer(self): def test_lcv_for_internal_transfer(self):
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note