diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index d54acb32d38..292f722aff0 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -38,6 +38,11 @@ "show_taxes_as_table_in_print", "column_break_12", "show_payment_schedule_in_print", + "item_price_settings_section", + "maintain_same_internal_transaction_rate", + "column_break_feyo", + "maintain_same_rate_action", + "role_to_override_stop_action", "currency_exchange_section", "allow_stale", "column_break_yuug", @@ -556,6 +561,37 @@ "fieldname": "legacy_section", "fieldtype": "Section Break", "label": "Legacy Fields" + }, + { + "default": "0", + "fieldname": "maintain_same_internal_transaction_rate", + "fieldtype": "Check", + "label": "Maintain Same Rate Throughout Internal Transaction" + }, + { + "default": "Stop", + "depends_on": "maintain_same_internal_transaction_rate", + "fieldname": "maintain_same_rate_action", + "fieldtype": "Select", + "label": "Action if Same Rate is Not Maintained Throughout Internal Transaction", + "mandatory_depends_on": "maintain_same_internal_transaction_rate", + "options": "Stop\nWarn" + }, + { + "depends_on": "eval: doc.maintain_same_internal_transaction_rate && doc.maintain_same_rate_action == 'Stop'", + "fieldname": "role_to_override_stop_action", + "fieldtype": "Link", + "label": "Role Allowed to Override Stop Action", + "options": "Role" + }, + { + "fieldname": "item_price_settings_section", + "fieldtype": "Section Break", + "label": "Item Price Settings" + }, + { + "fieldname": "column_break_feyo", + "fieldtype": "Column Break" } ], "icon": "icon-cog", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index ad39350f1c0..8dd73491072 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -50,6 +50,8 @@ class AccountsSettings(Document): general_ledger_remarks_length: DF.Int ignore_account_closing_balance: DF.Check ignore_is_opening_check_for_reporting: DF.Check + maintain_same_internal_transaction_rate: DF.Check + maintain_same_rate_action: DF.Literal["Stop", "Warn"] make_payment_via_journal_entry: DF.Check merge_similar_account_heads: DF.Check over_billing_allowance: DF.Currency @@ -58,6 +60,7 @@ class AccountsSettings(Document): receivable_payable_remarks_length: DF.Int reconciliation_queue_size: DF.Int role_allowed_to_over_bill: DF.Link | None + role_to_override_stop_action: DF.Link | None round_row_wise_tax: DF.Check show_balance_in_coa: DF.Check show_inclusive_tax_in_print: DF.Check diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 5e9c799fb86..3ad08885b94 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -64,6 +64,26 @@ class TestSalesInvoice(FrappeTestCase): ) frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None) + @change_settings( + "Accounts Settings", + {"maintain_same_internal_transaction_rate": 1, "maintain_same_rate_action": "Stop"}, + ) + def test_invalid_rate_without_override(self): + from frappe import ValidationError + + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_purchase_invoice + + si = create_sales_invoice( + customer="_Test Internal Customer 3", company="_Test Company", is_internal_customer=1, rate=100 + ) + pi = make_inter_company_purchase_invoice(si.name) + pi.items[0].rate = 120 + + with self.assertRaises(ValidationError) as e: + pi.insert() + pi.submit() + self.assertIn("Rate must be same", str(e.exception)) + def tearDown(self): frappe.db.rollback() @@ -4441,6 +4461,7 @@ def create_sales_invoice(**args): si.conversion_rate = args.conversion_rate or 1 si.naming_series = args.naming_series or "T-SINV-" si.cost_center = args.parent_cost_center + si.is_internal_customer = args.is_internal_customer or 0 bundle_id = None if si.update_stock and (args.get("batch_no") or args.get("serial_no")): @@ -4643,6 +4664,12 @@ def create_internal_parties(): allowed_to_interact_with="_Test Company with perpetual inventory", ) + create_internal_supplier( + supplier_name="_Test Internal Supplier 3", + represents_company="_Test Company", + allowed_to_interact_with="_Test Company", + ) + def create_internal_supplier(supplier_name, represents_company, allowed_to_interact_with): if not frappe.db.exists("Supplier", supplier_name): diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index c0815c89439..e33f8f8b64c 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -230,6 +230,8 @@ class AccountsController(TransactionBase): self.validate_party_accounts() self.validate_inter_company_reference() + # validate inter company transaction rate + self.validate_internal_transaction() self.disable_pricing_rule_on_internal_transfer() self.disable_tax_included_prices_for_internal_transfer() @@ -740,6 +742,91 @@ class AccountsController(TransactionBase): msg = f"At Row {row.idx}: The field {bold(label)} is mandatory for internal transfer" frappe.throw(_(msg), title=_("Internal Transfer Reference Missing")) + def validate_internal_transaction(self): + if not cint( + frappe.db.get_single_value("Accounts Settings", "maintain_same_internal_transaction_rate") + ): + return + + doctypes_list = ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"] + + if self.doctype in doctypes_list and ( + self.get("is_internal_customer") or self.get("is_internal_supplier") + ): + self.validate_internal_transaction_based_on_voucher_type() + + def validate_internal_transaction_based_on_voucher_type(self): + order = ["Sales Order", "Purchase Order"] + invoice = ["Sales Invoice", "Purchase Invoice"] + + if self.doctype in order and self.get("inter_company_order_reference"): + # Fetch the linked order + linked_doctype = "Sales Order" if self.doctype == "Purchase Order" else "Purchase Order" + self.validate_line_items( + linked_doctype, + "sales_order" if linked_doctype == "Sales Order" else "purchase_order", + "sales_order_item" if linked_doctype == "Sales Order" else "purchase_order_item", + ) + elif self.doctype in invoice and self.get("inter_company_invoice_reference"): + # Fetch the linked invoice + linked_doctype = "Sales Invoice" if self.doctype == "Purchase Invoice" else "Purchase Invoice" + self.validate_line_items( + linked_doctype, + "sales_invoice" if linked_doctype == "Sales Invoice" else "purchase_invoice", + "sales_invoice_item" if linked_doctype == "Sales Invoice" else "purchase_invoice_item", + ) + + def validate_line_items(self, ref_dt, ref_dn_field, ref_link_field): + action, role_allowed_to_override = frappe.get_cached_value( + "Accounts Settings", "None", ["maintain_same_rate_action", "role_to_override_stop_action"] + ) + + reference_names = [d.get(ref_link_field) for d in self.get("items") if d.get(ref_link_field)] + reference_details = self.get_reference_details(reference_names, ref_dt + " Item") + + stop_actions = [] + + for d in self.get("items"): + if d.get(ref_link_field): + ref_rate = reference_details.get(d.get(ref_link_field)) + if ref_rate is not None and abs(flt(d.rate - ref_rate, d.precision("rate"))) >= 0.01: + if action == "Stop": + user_roles = [ + r["role"] + for r in frappe.get_all( + "Has Role", filters={"parent": frappe.session.user}, fields=["role"] + ) + ] + if role_allowed_to_override not in user_roles: + stop_actions.append( + _("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format( + d.idx, + ref_dt, + self.inter_company_invoice_reference + if d.parenttype in ("Sales Invoice", "Purchase Invoice") + else d.get(ref_dn_field), + d.rate, + ref_rate, + ) + ) + else: + frappe.msgprint( + _("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format( + d.idx, + ref_dt, + self.inter_company_invoice_reference + if d.parenttype in ("Sales Invoice", "Purchase Invoice") + else d.get(ref_dn_field), + d.rate, + ref_rate, + ), + title=_("Warning"), + indicator="orange", + ) + + if stop_actions: + frappe.throw(stop_actions, as_list=True) + def disable_pricing_rule_on_internal_transfer(self): if not self.get("ignore_pricing_rule") and self.is_internal_transfer(): self.ignore_pricing_rule = 1