From c7b4806117796546d19b7018f1a3e1ee2001feb9 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 28 May 2026 20:10:45 +0530 Subject: [PATCH] refactor: extract party validation and inter-company logic into service classes - accounts/services/party_validation.py: PartyValidator class with single validate() entry point covering party frozen/disabled check, party accounts, currency, party account currency, address/contact, and company-linked addresses. AccountsController.get_party() kept as a shim (called by advances and payment_schedule services). - accounts/services/internal_transfer.py: InternalTransferService class with validate() (reference + transaction rate + pricing/tax disablers), set_account() for unrealized P&L, is_internal_transfer(), process_common_party_accounting(), and get_common_party_link(). Shims retained on AccountsController for the three methods called by selling/buying/stock controllers and GL composers. accounts_controller.py drops from ~2722 to ~2356 lines. --- .../accounts/services/internal_transfer.py | 196 +++++++++ erpnext/accounts/services/party_validation.py | 223 ++++++++++ erpnext/controllers/accounts_controller.py | 405 +----------------- 3 files changed, 438 insertions(+), 386 deletions(-) create mode 100644 erpnext/accounts/services/internal_transfer.py create mode 100644 erpnext/accounts/services/party_validation.py diff --git a/erpnext/accounts/services/internal_transfer.py b/erpnext/accounts/services/internal_transfer.py new file mode 100644 index 00000000000..fdce48e0815 --- /dev/null +++ b/erpnext/accounts/services/internal_transfer.py @@ -0,0 +1,196 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Internal transfer helpers: InternalTransferService for inter-company transaction validation and setup.""" + +import frappe +from frappe import _, bold +from frappe.utils import cint, flt + + +class InternalTransferService: + """Handles validation and setup for inter-company / internal transfer transactions.""" + + def __init__(self, doc): + self.doc = doc + + def is_internal_transfer(self) -> bool: + """Return True if document is an internal transfer (internal party + same represents_company).""" + doc = self.doc + if doc.doctype in ("Sales Invoice", "Delivery Note", "Sales Order"): + internal_party_field = "is_internal_customer" + elif doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Purchase Order"): + internal_party_field = "is_internal_supplier" + else: + return False + + return bool(doc.get(internal_party_field) and doc.represents_company == doc.company) + + def validate(self) -> None: + """Run all inter-company validations and apply internal-transfer field overrides.""" + self.validate_reference() + self.validate_transaction() + self.disable_pricing_rule() + self.disable_tax_included_prices() + + def set_account(self) -> None: + """Set unrealized profit/loss account for internal transfers (SI/PI only).""" + if not self.is_internal_transfer() or self.doc.unrealized_profit_loss_account: + return + + unrealized_profit_loss_account = frappe.get_cached_value( + "Company", self.doc.company, "unrealized_profit_loss_account" + ) + + if not unrealized_profit_loss_account: + frappe.throw( + _( + "Please select Unrealized Profit / Loss account or add default Unrealized Profit / Loss account account for company {0}" + ).format(frappe.bold(self.doc.company)) + ) + + self.doc.unrealized_profit_loss_account = unrealized_profit_loss_account + + def process_common_party_accounting(self) -> None: + """Auto-create and reconcile advance for common party links (called from on_submit).""" + if self.doc.doctype not in ("Sales Invoice", "Purchase Invoice"): + return + + if frappe.get_single_value("Accounts Settings", "enable_common_party_accounting"): + party_link = self.get_common_party_link() + if party_link and self.doc.outstanding_amount: + from erpnext.accounts.services.advances import create_advance_and_reconcile + + create_advance_and_reconcile(self.doc, party_link) + + def get_common_party_link(self) -> frappe._dict | None: + party_type, party = self.doc.get_party() + return frappe.db.get_value( + doctype="Party Link", + filters={"secondary_role": party_type, "secondary_party": party}, + fieldname=["primary_role", "primary_party"], + as_dict=True, + ) + + def validate_reference(self) -> None: + if self.doc.get("is_return"): + return + if self.doc.doctype not in ("Purchase Invoice", "Purchase Receipt"): + return + if not self.is_internal_transfer(): + return + + if not ( + self.doc.get("inter_company_reference") + or self.doc.get("inter_company_invoice_reference") + or self.doc.get("inter_company_order_reference") + ): + msg = _("Internal Sale or Delivery Reference missing.") + msg += _("Please create purchase from internal sale or delivery document itself") + frappe.throw(msg, title=_("Internal Sales Reference Missing")) + + label = "Delivery Note Item" if self.doc.doctype == "Purchase Receipt" else "Sales Invoice Item" + field = frappe.scrub(label) + + for row in self.doc.get("items"): + if not row.get(field): + frappe.throw( + _(f"At Row {row.idx}: The field {bold(label)} is mandatory for internal transfer"), + title=_("Internal Transfer Reference Missing"), + ) + + def validate_transaction(self) -> None: + if not cint(frappe.get_single_value("Accounts Settings", "maintain_same_internal_transaction_rate")): + return + + applicable_doctypes = ("Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice") + if self.doc.doctype not in applicable_doctypes: + return + if not (self.doc.get("is_internal_customer") or self.doc.get("is_internal_supplier")): + return + + self._validate_transaction_by_voucher_type() + + def disable_pricing_rule(self) -> None: + if not self.doc.get("ignore_pricing_rule") and self.is_internal_transfer(): + self.doc.ignore_pricing_rule = 1 + frappe.msgprint( + _("Disabled pricing rules since this {} is an internal transfer").format(self.doc.doctype), + alert=1, + ) + + def disable_tax_included_prices(self) -> None: + if not self.is_internal_transfer(): + return + + tax_updated = False + for tax in self.doc.get("taxes"): + if tax.get("included_in_print_rate"): + tax.included_in_print_rate = 0 + tax_updated = True + + if tax_updated: + frappe.msgprint( + _("Disabled tax included prices since this {} is an internal transfer").format( + self.doc.doctype + ), + alert=1, + ) + + def _validate_transaction_by_voucher_type(self) -> None: + orders = ("Sales Order", "Purchase Order") + invoices = ("Sales Invoice", "Purchase Invoice") + + if self.doc.doctype in orders and self.doc.get("inter_company_order_reference"): + linked_doctype = "Sales Order" if self.doc.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.doc.doctype in invoices and self.doc.get("inter_company_invoice_reference"): + linked_doctype = "Sales Invoice" if self.doc.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: str, ref_dn_field: str, ref_link_field: str) -> None: + 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.doc.get("items") if d.get(ref_link_field)] + reference_details = self.doc.get_reference_details(reference_names, ref_dt + " Item") + + stop_actions = [] + + for d in self.doc.get("items"): + if not d.get(ref_link_field): + continue + + ref_rate = reference_details.get(d.get(ref_link_field)) + if ref_rate is None or abs(flt(d.rate - ref_rate, d.precision("rate"))) < 0.01: + continue + + ref_name = ( + self.doc.inter_company_invoice_reference + if d.parenttype in ("Sales Invoice", "Purchase Invoice") + else d.get(ref_dn_field) + ) + msg = _("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format( + d.idx, ref_dt, ref_name, d.rate, ref_rate + ) + + if action == "Stop": + user_roles = frappe.get_all( + "Has Role", filters={"parent": frappe.session.user}, fields=["role"], pluck="role" + ) + if role_allowed_to_override not in user_roles: + stop_actions.append(msg) + else: + frappe.msgprint(msg, title=_("Warning"), indicator="orange") + + if stop_actions: + frappe.throw(stop_actions, as_list=True) diff --git a/erpnext/accounts/services/party_validation.py b/erpnext/accounts/services/party_validation.py new file mode 100644 index 00000000000..a6ec9716840 --- /dev/null +++ b/erpnext/accounts/services/party_validation.py @@ -0,0 +1,223 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Party validation: PartyValidator class for transaction-level party checks.""" + +import frappe +from frappe import _ + +from erpnext.accounts.party import ( + get_party_account_currency, + get_party_gle_currency, + validate_party_frozen_disabled, +) +from erpnext.accounts.utils import get_account_currency +from erpnext.exceptions import InvalidCurrency + + +class PartyValidator: + """Validates all party-related fields on a transaction document.""" + + def __init__(self, doc): + self.doc = doc + + def validate(self) -> None: + """Run all party-related validations in order.""" + self.validate_party() + self.validate_party_accounts() + self.validate_currency() + self.validate_party_account_currency() + self.validate_address_and_contact() + self.validate_company_linked_addresses() + + def get_party(self) -> tuple[str | None, str | None]: + """Return (party_type, party_name) for the document.""" + doc = self.doc + party_type = None + + if doc.doctype in ("Opportunity", "Quotation", "Sales Order", "Delivery Note", "Sales Invoice"): + party_type = "Customer" + elif doc.doctype in ( + "Supplier Quotation", + "Purchase Order", + "Purchase Receipt", + "Purchase Invoice", + ): + party_type = "Supplier" + elif doc.meta.get_field("customer"): + party_type = "Customer" + elif doc.meta.get_field("supplier"): + party_type = "Supplier" + + party = doc.get(party_type.lower()) if party_type else None + return party_type, party + + def validate_party(self) -> None: + party_type, party = self.get_party() + validate_party_frozen_disabled(self.doc.company, party_type, party) + + def validate_party_accounts(self) -> None: + if self.doc.doctype not in ("Sales Invoice", "Purchase Invoice"): + return + + if self.doc.doctype == "Sales Invoice": + party_account_field = "debit_to" + item_field = "income_account" + else: + party_account_field = "credit_to" + item_field = "expense_account" + + for item in self.doc.get("items"): + if item.get(item_field) == self.doc.get(party_account_field): + frappe.throw( + _("Row {0}: {1} {2} cannot be same as {3} (Party Account) {4}").format( + item.idx, + frappe.bold(frappe.unscrub(item_field)), + item.get(item_field), + frappe.bold(frappe.unscrub(party_account_field)), + self.doc.get(party_account_field), + ) + ) + + def validate_currency(self) -> None: + if not self.doc.get("currency"): + return + + party_type, party = self.get_party() + if not (party_type and party): + return + + party_account_currency = get_party_account_currency(party_type, party, self.doc.company) + + if ( + party_account_currency + and party_account_currency != self.doc.company_currency + and self.doc.currency != party_account_currency + ): + frappe.throw( + _("Accounting Entry for {0}: {1} can only be made in currency: {2}").format( + party_type, party, party_account_currency + ), + InvalidCurrency, + ) + + def validate_party_account_currency(self) -> None: + if self.doc.doctype not in ("Sales Invoice", "Purchase Invoice"): + return + if self.doc.is_opening == "Yes": + return + + party_type, party = self.get_party() + party_gle_currency = get_party_gle_currency(party_type, party, self.doc.company) + party_account = ( + self.doc.get("debit_to") if self.doc.doctype == "Sales Invoice" else self.doc.get("credit_to") + ) + party_account_currency = get_account_currency(party_account) + allow_multi_currency = frappe.db.get_singles_value( + "Accounts Settings", "allow_multi_currency_invoices_against_single_party_account" + ) + + if ( + not party_gle_currency + and party_account_currency != self.doc.currency + and not allow_multi_currency + ): + frappe.throw( + _("Party Account {0} currency ({1}) and document currency ({2}) should be same").format( + frappe.bold(party_account), party_account_currency, self.doc.currency + ) + ) + + def validate_address_and_contact(self) -> None: + party_type, party = self.get_party() + if not (party_type and party): + return + + if party_type == "Customer": + self._validate_address( + party, + party_type, + self.doc.get("customer_address"), + self.doc.get("shipping_address_name"), + ) + elif party_type == "Supplier": + self._validate_address(party, party_type, self.doc.get("supplier_address")) + + self._validate_contact(party, party_type) + + def validate_company_linked_addresses(self) -> None: + doc = self.doc + sales_doctypes = ("Quotation", "Sales Order", "Delivery Note", "Sales Invoice") + purchase_doctypes = ("Purchase Order", "Purchase Receipt", "Purchase Invoice", "Supplier Quotation") + + if doc.doctype in sales_doctypes: + address_fields = ["dispatch_address_name", "company_address"] + elif doc.doctype in purchase_doctypes: + address_fields = ["billing_address", "shipping_address"] + else: + return + + is_drop_ship = ( + doc.doctype + in { + "Purchase Order", + "Purchase Invoice", + "Sales Order", + "Sales Invoice", + } + and self._is_drop_ship() + ) + + for field in address_fields: + address = doc.get(field) + if field in ("dispatch_address_name", "shipping_address") and is_drop_ship: + continue + if address and not frappe.db.exists( + "Dynamic Link", + { + "parent": address, + "parenttype": "Address", + "link_doctype": "Company", + "link_name": doc.company, + }, + ): + frappe.throw( + _("{0} does not belong to the Company {1}.").format( + _(doc.meta.get_label(field)), frappe.bold(doc.company) + ) + ) + + def _validate_address( + self, + party: str, + party_type: str, + billing_address: str | None, + shipping_address: str | None = None, + ) -> None: + if not (billing_address or shipping_address): + return + + party_addresses = frappe.get_all( + "Dynamic Link", + {"link_doctype": party_type, "link_name": party, "parenttype": "Address"}, + pluck="parent", + ) + if billing_address and billing_address not in party_addresses: + frappe.throw(_("Billing Address does not belong to the {0}").format(party)) + elif shipping_address and shipping_address not in party_addresses: + frappe.throw(_("Shipping Address does not belong to the {0}").format(party)) + + def _validate_contact(self, party: str, party_type: str) -> None: + if not self.doc.get("contact_person"): + return + + contacts = frappe.get_all( + "Dynamic Link", + {"link_doctype": party_type, "link_name": party, "parenttype": "Contact"}, + pluck="parent", + ) + if self.doc.contact_person not in contacts: + frappe.throw(_("Contact Person does not belong to the {0}").format(party)) + + def _is_drop_ship(self) -> bool: + return any(item.delivered_by_supplier for item in self.doc.items) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index c17f9d5871f..e3fae9b2b13 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -35,25 +35,17 @@ from erpnext.accounts.general_ledger import get_round_off_account_and_cost_cente from erpnext.accounts.party import ( PURCHASE_TRANSACTION_TYPES, SALES_TRANSACTION_TYPES, - get_party_account, - get_party_account_currency, - get_party_gle_currency, - validate_party_frozen_disabled, -) -from erpnext.accounts.utils import ( - get_account_currency, - validate_fiscal_year, ) from erpnext.accounts.utils import ( get_advance_payment_doctypes as _get_advance_payment_doctypes, ) +from erpnext.accounts.utils import validate_fiscal_year from erpnext.buying.utils import update_last_purchase_rate from erpnext.controllers.print_settings import ( set_print_templates_for_item_table, set_print_templates_for_taxes, ) from erpnext.controllers.sales_and_purchase_return import validate_return -from erpnext.exceptions import InvalidCurrency from erpnext.setup.utils import get_exchange_rate from erpnext.stock.doctype.item.item import get_uom_conv_factor from erpnext.stock.doctype.packed_item.packed_item import make_packing_list @@ -225,19 +217,15 @@ class AccountsController(TransactionBase): self.ensure_supplier_is_not_blocked() self.validate_date_with_fiscal_year() - self.validate_party_accounts() if self.doctype in ["Sales Invoice", "Purchase Invoice"]: if self.is_return: self.validate_qty() else: self.validate_deferred_start_and_end_date() - self.validate_inter_company_reference() - # validate inter company transaction rate - self.validate_internal_transaction() + from erpnext.accounts.services.internal_transfer import InternalTransferService - self.disable_pricing_rule_on_internal_transfer() - self.disable_tax_included_prices_for_internal_transfer() + InternalTransferService(self).validate() self.set_incoming_rate() self.init_internal_values() self.validate_against_voucher_outstanding() @@ -263,9 +251,9 @@ class AccountsController(TransactionBase): self.validate_all_documents_schedule() - self.validate_party() - self.validate_currency() - self.validate_party_account_currency() + from erpnext.accounts.services.party_validation import PartyValidator + + PartyValidator(self).validate() self.validate_return_against_account() if self.doctype in ["Purchase Invoice", "Sales Invoice"]: @@ -286,7 +274,7 @@ class AccountsController(TransactionBase): self.set_advance_gain_or_loss() self.validate_deferred_income_expense_account() - self.set_inter_company_account() + InternalTransferService(self).set_account() if self.doctype == "Purchase Invoice": self.calculate_paid_amount() @@ -301,54 +289,6 @@ class AccountsController(TransactionBase): self.set_total_in_words() self.set_default_letter_head() self.validate_company_in_accounting_dimension() - self.validate_party_address_and_contact() - self.validate_company_linked_addresses() - - def validate_company_linked_addresses(self): - address_fields = [] - sales_doctypes = ("Quotation", "Sales Order", "Delivery Note", "Sales Invoice") - purchase_doctypes = ("Purchase Order", "Purchase Receipt", "Purchase Invoice", "Supplier Quotation") - - if self.doctype in sales_doctypes: - address_fields = ["dispatch_address_name", "company_address"] - elif self.doctype in purchase_doctypes: - address_fields = ["billing_address", "shipping_address"] - - if not address_fields: - return - - # Determine if drop ship applies - is_drop_ship = self.doctype in { - "Purchase Order", - "Purchase Invoice", - "Sales Order", - "Sales Invoice", - } and self.is_drop_ship(self.items) - - for field in address_fields: - address = self.get(field) - - if (field in ["dispatch_address_name", "shipping_address"]) and is_drop_ship: - continue - - if address and not frappe.db.exists( - "Dynamic Link", - { - "parent": address, - "parenttype": "Address", - "link_doctype": "Company", - "link_name": self.company, - }, - ): - frappe.throw( - _("{0} does not belong to the Company {1}.").format( - _(self.meta.get_label(field)), bold(self.company) - ) - ) - - @staticmethod - def is_drop_ship(items): - return any(item.delivered_by_supplier for item in items) def set_default_letter_head(self): if hasattr(self, "letter_head") and not self.letter_head: @@ -536,46 +476,6 @@ class AccountsController(TransactionBase): ) ) - def validate_party_address_and_contact(self): - party_type, party = self.get_party() - - if not (party_type and party): - return - - if party_type == "Customer": - billing_address, shipping_address = ( - self.get("customer_address"), - self.get("shipping_address_name"), - ) - self.validate_party_address(party, party_type, billing_address, shipping_address) - elif party_type == "Supplier": - billing_address = self.get("supplier_address") - self.validate_party_address(party, party_type, billing_address) - - self.validate_party_contact(party, party_type) - - def validate_party_address(self, party, party_type, billing_address, shipping_address=None): - if billing_address or shipping_address: - party_address = frappe.get_all( - "Dynamic Link", - {"link_doctype": party_type, "link_name": party, "parenttype": "Address"}, - pluck="parent", - ) - if billing_address and billing_address not in party_address: - frappe.throw(_("Billing Address does not belong to the {0}").format(party)) - elif shipping_address and shipping_address not in party_address: - frappe.throw(_("Shipping Address does not belong to the {0}").format(party)) - - def validate_party_contact(self, party, party_type): - if self.get("contact_person"): - contact = frappe.get_all( - "Dynamic Link", - {"link_doctype": party_type, "link_name": party, "parenttype": "Contact"}, - pluck="parent", - ) - if self.contact_person and self.contact_person not in contact: - frappe.throw(_("Contact Person does not belong to the {0}").format(party)) - def validate_return_against_account(self): if self.doctype in ["Sales Invoice", "Purchase Invoice"] and self.is_return and self.return_against: cr_dr_account_field = "debit_to" if self.doctype == "Sales Invoice" else "credit_to" @@ -762,162 +662,6 @@ class AccountsController(TransactionBase): self, ) - def validate_party_accounts(self): - if self.doctype not in ("Sales Invoice", "Purchase Invoice"): - return - - if self.doctype == "Sales Invoice": - party_account_field = "debit_to" - item_field = "income_account" - else: - party_account_field = "credit_to" - item_field = "expense_account" - - for item in self.get("items"): - if item.get(item_field) == self.get(party_account_field): - frappe.throw( - _("Row {0}: {1} {2} cannot be same as {3} (Party Account) {4}").format( - item.idx, - frappe.bold(frappe.unscrub(item_field)), - item.get(item_field), - frappe.bold(frappe.unscrub(party_account_field)), - self.get(party_account_field), - ) - ) - - def validate_inter_company_reference(self): - if self.get("is_return"): - return - - if self.doctype not in ("Purchase Invoice", "Purchase Receipt"): - return - - if self.is_internal_transfer(): - if not ( - self.get("inter_company_reference") - or self.get("inter_company_invoice_reference") - or self.get("inter_company_order_reference") - ) and not self.get("is_return"): - msg = _("Internal Sale or Delivery Reference missing.") - msg += _("Please create purchase from internal sale or delivery document itself") - frappe.throw(msg, title=_("Internal Sales Reference Missing")) - - label = "Delivery Note Item" if self.doctype == "Purchase Receipt" else "Sales Invoice Item" - - field = frappe.scrub(label) - - for row in self.get("items"): - if not row.get(field): - 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.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 - frappe.msgprint( - _("Disabled pricing rules since this {} is an internal transfer").format(self.doctype), - alert=1, - ) - - def disable_tax_included_prices_for_internal_transfer(self): - if self.is_internal_transfer(): - tax_updated = False - for tax in self.get("taxes"): - if tax.get("included_in_print_rate"): - tax.included_in_print_rate = 0 - tax_updated = True - - if tax_updated: - frappe.msgprint( - _("Disabled tax included prices since this {} is an internal transfer").format( - self.doctype - ), - alert=1, - ) - def validate_due_date(self): if self.get("is_pos") or self.doctype not in ["Sales Invoice", "Purchase Invoice"]: return @@ -1546,80 +1290,10 @@ class AccountsController(TransactionBase): frappe.throw(message, title=_("Account Missing"), exc=AccountMissingError) - def validate_party(self): - party_type, party = self.get_party() - validate_party_frozen_disabled(self.company, party_type, party) + def get_party(self) -> tuple[str | None, str | None]: + from erpnext.accounts.services.party_validation import PartyValidator - def get_party(self): - party_type = None - if self.doctype in ("Opportunity", "Quotation", "Sales Order", "Delivery Note", "Sales Invoice"): - party_type = "Customer" - - elif self.doctype in ( - "Supplier Quotation", - "Purchase Order", - "Purchase Receipt", - "Purchase Invoice", - ): - party_type = "Supplier" - - elif self.meta.get_field("customer"): - party_type = "Customer" - - elif self.meta.get_field("supplier"): - party_type = "Supplier" - - party = self.get(party_type.lower()) if party_type else None - - return party_type, party - - def validate_currency(self): - if self.get("currency"): - party_type, party = self.get_party() - if party_type and party: - party_account_currency = get_party_account_currency(party_type, party, self.company) - - if ( - party_account_currency - and party_account_currency != self.company_currency - and self.currency != party_account_currency - ): - frappe.throw( - _("Accounting Entry for {0}: {1} can only be made in currency: {2}").format( - party_type, party, party_account_currency - ), - InvalidCurrency, - ) - - # Note: not validating with gle account because we don't have the account - # at quotation / sales order level and we shouldn't stop someone - # from creating a sales invoice if sales order is already created - - def validate_party_account_currency(self): - if self.doctype not in ("Sales Invoice", "Purchase Invoice"): - return - - if self.is_opening == "Yes": - return - - party_type, party = self.get_party() - party_gle_currency = get_party_gle_currency(party_type, party, self.company) - party_account = self.get("debit_to") if self.doctype == "Sales Invoice" else self.get("credit_to") - party_account_currency = get_account_currency(party_account) - allow_multi_currency_invoices_against_single_party_account = frappe.db.get_singles_value( - "Accounts Settings", "allow_multi_currency_invoices_against_single_party_account" - ) - - if ( - not party_gle_currency - and (party_account_currency != self.currency) - and not allow_multi_currency_invoices_against_single_party_account - ): - frappe.throw( - _("Party Account {0} currency ({1}) and document currency ({2}) should be same").format( - frappe.bold(party_account), party_account_currency, self.currency - ) - ) + return PartyValidator(self).get_party() def delink_advance_entries(self, linked_doc_name): from erpnext.accounts.services.advances import delink_advance_entries @@ -1666,61 +1340,20 @@ class AccountsController(TransactionBase): else: return frappe.db.get_single_value("Global Defaults", "disable_rounded_total") - def set_inter_company_account(self): - """ - Set intercompany account for inter warehouse transactions - This account will be used in case billing company and internal customer's - representation company is same - """ + def is_internal_transfer(self) -> bool: + from erpnext.accounts.services.internal_transfer import InternalTransferService - if self.is_internal_transfer() and not self.unrealized_profit_loss_account: - unrealized_profit_loss_account = frappe.get_cached_value( - "Company", self.company, "unrealized_profit_loss_account" - ) + return InternalTransferService(self).is_internal_transfer() - if not unrealized_profit_loss_account: - msg = _( - "Please select Unrealized Profit / Loss account or add default Unrealized Profit / Loss account account for company {0}" - ).format(frappe.bold(self.company)) - frappe.throw(msg) + def process_common_party_accounting(self) -> None: + from erpnext.accounts.services.internal_transfer import InternalTransferService - self.unrealized_profit_loss_account = unrealized_profit_loss_account + InternalTransferService(self).process_common_party_accounting() - def is_internal_transfer(self): - """ - It will an internal transfer if its an internal customer and representation - company is same as billing company - """ - if self.doctype in ("Sales Invoice", "Delivery Note", "Sales Order"): - internal_party_field = "is_internal_customer" - elif self.doctype in ("Purchase Invoice", "Purchase Receipt", "Purchase Order"): - internal_party_field = "is_internal_supplier" - else: - return False + def get_common_party_link(self) -> frappe._dict | None: + from erpnext.accounts.services.internal_transfer import InternalTransferService - if self.get(internal_party_field) and (self.represents_company == self.company): - return True - - return False - - def process_common_party_accounting(self): - is_invoice = self.doctype in ["Sales Invoice", "Purchase Invoice"] - if not is_invoice: - return - - if frappe.get_single_value("Accounts Settings", "enable_common_party_accounting"): - party_link = self.get_common_party_link() - if party_link and self.outstanding_amount: - self.create_advance_and_reconcile(party_link) - - def get_common_party_link(self): - party_type, party = self.get_party() - return frappe.db.get_value( - doctype="Party Link", - filters={"secondary_role": party_type, "secondary_party": party}, - fieldname=["primary_role", "primary_party"], - as_dict=True, - ) + return InternalTransferService(self).get_common_party_link() def create_advance_and_reconcile(self, party_link): from erpnext.accounts.services.advances import create_advance_and_reconcile