diff --git a/.mergify.yml b/.mergify.yml index 5e558062048..95763b27cb2 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -88,7 +88,6 @@ pull_request_rules: actions: merge: method: squash - commit_message_template: | - {{ title }} (#{{ number }}) - - {{ body }} + commit_message_format: + title: pr-title + body: pr-body diff --git a/crowdin.yml b/crowdin.yml index 3782fb6dd32..7c1ce470fb7 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -12,3 +12,5 @@ append_commit_message: false languages_mapping: two_letters_code: pt-BR: pt_BR + zh-CN: zh + zh-TW: zh_TW diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index ebfb2d0bcee..6ed89c22f24 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -121,6 +121,7 @@ class Account(NestedSet): self.validate_account_currency() self.validate_root_company_and_sync_account_to_children() self.validate_receivable_payable_account_type() + self.validate_stock_account_type_change() def validate_parent_child_account_type(self): if self.parent_account: @@ -212,6 +213,36 @@ class Account(NestedSet): frappe.msgprint(msg) self.add_comment("Comment", msg) + def validate_stock_account_type_change(self): + doc_before_save = self.get_doc_before_save() + if not (doc_before_save and doc_before_save.account_type == "Stock"): + return + + if self.account_type == "Stock": + return + + if self.stock_ledger_entry_exists(): + frappe.throw( + _( + "The account type of {0} cannot be changed from {1} because stock ledger entries exist against it." + ).format(frappe.bold(self.name), frappe.bold(_("Stock"))) + ) + + def stock_ledger_entry_exists(self): + from erpnext.stock import get_warehouse_account_map + + warehouse_account = get_warehouse_account_map(self.company) + warehouses = [wh for wh, details in warehouse_account.items() if details.account == self.name] + if not warehouses: + return False + + return bool( + frappe.db.count( + "Stock Ledger Entry", + filters={"warehouse": ("in", warehouses), "is_cancelled": 0}, + ) + ) + def validate_root_details(self): doc_before_save = self.get_doc_before_save() @@ -659,8 +690,15 @@ def _ensure_idle_system(): last_gl_update = None try: - # We also lock inserts to GL entry table with for_update here. - last_gl_update = frappe.db.get_value("GL Entry", {}, "modified", for_update=True, wait=False) + if frappe.db.db_type == "postgres": + # The MariaDB branch blocks new GL inserts via the gap lock its for_update read takes; + # a postgres row lock never blocks inserts, so take an EXCLUSIVE table lock instead -- + # writers block until the rename commits, readers don't. NOWAIT mirrors wait=False. + frappe.db.sql("LOCK TABLE `tabGL Entry` IN EXCLUSIVE MODE NOWAIT") + last_gl_update = frappe.db.get_value("GL Entry", {}, "modified") + else: + # We also lock inserts to GL entry table with for_update here. + last_gl_update = frappe.db.get_value("GL Entry", {}, "modified", for_update=True, wait=False) except frappe.QueryTimeoutError: # wait=False fails immediately if there's an active transaction. last_gl_update = add_to_date(None, seconds=-1) diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json index 515a1e4de9d..a55dd3a183d 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json @@ -24,7 +24,8 @@ "account_number": "11530" }, "account_number": "115", - "is_group": 1 + "is_group": 1, + "account_type": "Bank" }, "Trade Receivables": { "Trade Debtors": { @@ -529,6 +530,13 @@ "account_number": "630", "is_group": 1 }, + "Accrued Manufacturing Expenses": { + "Accrued Expenses - Manufacturing": { + "account_number": "63510" + }, + "account_number": "635", + "is_group": 1 + }, "account_number": "63", "is_group": 1 }, @@ -814,4 +822,4 @@ "root_type": "Expense" } } -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json index 30a3baf83e2..312c3832f54 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json @@ -22,12 +22,12 @@ "account_type": "Cash" }, "Petty Cash Fund": { - "account_number": "1200", + "account_number": "1110", "is_group": 1, "root_type": "Asset", "account_type": "Cash", "Petty Cash Fund": { - "account_number": "1201", + "account_number": "1111", "is_group": 0, "root_type": "Asset", "account_type": "Cash" @@ -35,10 +35,16 @@ } }, "Bank Accounts": { - "account_number": "1102", + "account_number": "1200", "is_group": 1, "root_type": "Asset", - "account_type": "Bank" + "account_type": "Bank", + "Cash in Bank - Checking Account": { + "account_number": "1201", + "is_group": 0, + "root_type": "Asset", + "account_type": "Bank" + } }, "Advances to Officers & Employees": { "account_number": "1290", @@ -104,25 +110,20 @@ "account_number": "1511", "is_group": 0, "root_type": "Asset" - }, - "Factory Overhead Variance": { - "account_number": "1512", - "is_group": 0, - "root_type": "Asset" } }, "Finished Goods": { - "account_number": "1520", + "account_number": "1540", "is_group": 1, "root_type": "Asset", "Finished Goods Inventory": { - "account_number": "1531", + "account_number": "1541", "is_group": 0, "root_type": "Asset", "account_type": "Stock" }, "Inventory in Transit": { - "account_number": "1532", + "account_number": "1542", "is_group": 0, "root_type": "Asset", "account_type": "Stock Adjustment" @@ -268,7 +269,7 @@ "root_type": "Asset" } }, - "System Development": { + "Intangible Assets": { "account_number": "1940", "is_group": 1, "root_type": "Asset", @@ -277,6 +278,17 @@ "is_group": 0, "root_type": "Asset" } + }, + "Accumulated Amortization - Intangible Assets": { + "account_number": "1950", + "is_group": 1, + "root_type": "Asset", + "Accum Amortization - System Development": { + "account_number": "1951", + "is_group": 0, + "root_type": "Asset", + "account_type": "Accumulated Depreciation" + } } } }, @@ -406,8 +418,7 @@ "Customer Deposits": { "account_number": "2500", "is_group": 0, - "root_type": "Liability", - "account_type": "Payable" + "root_type": "Liability" } }, "Non Current Liabilities": { @@ -563,6 +574,28 @@ "is_group": 0, "root_type": "Income" } + }, + "Exchange Gain": { + "account_number": "6030", + "is_group": 1, + "root_type": "Income", + "Exchange Gain - Detail": { + "account_number": "6031", + "is_group": 0, + "root_type": "Income", + "account_type": "Indirect Income" + } + }, + "Gain on Asset Disposal": { + "account_number": "6040", + "is_group": 1, + "root_type": "Income", + "Gain on Asset Disposal - Detail": { + "account_number": "6041", + "is_group": 0, + "root_type": "Income", + "account_type": "Indirect Income" + } } } }, @@ -575,7 +608,7 @@ "is_group": 1, "root_type": "Expense", "Cost of Goods Sold": { - "account_number": "5010", + "account_number": "5002", "is_group": 0, "root_type": "Expense", "account_type": "Cost of Goods Sold" @@ -828,20 +861,61 @@ "root_type": "Expense" } }, - "Stock Adjustment": { + "Other Expenses": { "account_number": "5200", + "is_group": 1, + "root_type": "Expense", + "Bank Charges": { + "account_number": "5201", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + }, + "Interest Expenses Bank": { + "account_number": "5202", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + }, + "Write Off": { + "account_number": "5203", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + }, + "Exchange Loss": { + "account_number": "5204", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + }, + "Loss on Asset Disposal": { + "account_number": "5205", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + } + }, + "Provision For Income Tax": { + "account_number": "5300", + "is_group": 0, + "root_type": "Expense", + "account_type": "Tax" + }, + "Stock Adjustment": { + "account_number": "5400", "is_group": 0, "root_type": "Expense", "account_type": "Stock Adjustment" }, "Round Off": { - "account_number": "5300", + "account_number": "5500", "is_group": 0, "root_type": "Expense", "account_type": "Round Off" }, "Expenses Included In Valuation": { - "account_number": "5400", + "account_number": "5600", "is_group": 0, "root_type": "Expense", "account_type": "Expenses Included In Valuation" diff --git a/erpnext/accounts/doctype/account/test_account.py b/erpnext/accounts/doctype/account/test_account.py index cdc278567a5..be592d78b43 100644 --- a/erpnext/accounts/doctype/account/test_account.py +++ b/erpnext/accounts/doctype/account/test_account.py @@ -306,6 +306,31 @@ class TestAccount(ERPNextTestSuite): acc.account_currency = "USD" self.assertRaises(frappe.ValidationError, acc.save) + def test_stock_account_type_change_with_ledger_entries(self): + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + stock_account = get_warehouse_account(frappe.get_doc("Warehouse", warehouse)) + + make_stock_entry( + item_code="_Test Item", + target=warehouse, + company=company, + qty=5, + basic_rate=100, + ) + + account = frappe.get_doc("Account", stock_account) + self.assertEqual(account.account_type, "Stock") + + account.account_type = "" + self.assertRaises(frappe.ValidationError, account.save) + + account.reload() + account.account_name = f"{account.account_name} Updated" + account.save() # non-type change stays allowed + def test_account_balance(self): from erpnext.accounts.utils import get_balance_on diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index da92cdd5b0a..1c7a4d488e5 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -22,6 +22,8 @@ "allow_multi_currency_invoices_against_single_party_account", "confirm_before_resetting_posting_date", "preview_mode", + "stock_expense_section", + "book_stock_expense_gl_entries", "analytics_section", "enable_discounts_and_margin", "enable_accounting_dimensions", @@ -76,6 +78,8 @@ "over_billing_allowance", "credit_controller", "role_allowed_to_over_bill", + "enable_overdue_billing_threshold", + "role_allowed_to_bypass_overdue_billing", "column_break_11", "assets_tab", "asset_settings_section", @@ -272,6 +276,21 @@ "label": "Role Allowed to over bill ", "options": "Role" }, + { + "default": "0", + "description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.", + "fieldname": "enable_overdue_billing_threshold", + "fieldtype": "Check", + "label": "Restrict Customer Over Billing" + }, + { + "depends_on": "eval:doc.enable_overdue_billing_threshold", + "description": "Users with this role can still submit invoices for customers who have crossed their Overdue Limit.", + "fieldname": "role_allowed_to_bypass_overdue_billing", + "fieldtype": "Link", + "label": "Role Allowed to Bypass Over Billing Restriction", + "options": "Role" + }, { "fieldname": "period_closing_settings_section", "fieldtype": "Section Break" @@ -757,6 +776,18 @@ "description": "Changing the account in any transaction of the DocTypes listed below will trigger a repost. To prevent reposting, remove the relevant DocType from the list.", "fieldname": "column_break_mfor", "fieldtype": "Column Break" + }, + { + "fieldname": "stock_expense_section", + "fieldtype": "Section Break", + "label": "Stock Expense Accounting" + }, + { + "default": "0", + "description": "Books Purchase Expense and Expenses Added To Stock account pairs against stock value. On enabling this, the accounts become mandatory in Company or Item Defaults for Purchase Receipt, Purchase Invoice, Stock Entry, Stock Reconciliation and Landed Cost Voucher", + "fieldname": "book_stock_expense_gl_entries", + "fieldtype": "Check", + "label": "Book Stock Expense GL Entries" } ], "grid_page_length": 50, @@ -765,7 +796,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-06-24 12:59:41.868865", + "modified": "2026-07-15 17:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index c56d39ad8d9..b46f01152aa 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -62,6 +62,7 @@ class AccountsSettings(Document): book_asset_depreciation_entry_automatically: DF.Check book_deferred_entries_based_on: DF.Literal["Days", "Months"] book_deferred_entries_via_journal_entry: DF.Check + book_stock_expense_gl_entries: DF.Check book_tax_discount_loss: DF.Check calculate_depr_using_total_days: DF.Check check_supplier_invoice_uniqueness: DF.Check @@ -77,6 +78,7 @@ class AccountsSettings(Document): enable_fuzzy_matching: DF.Check enable_immutable_ledger: DF.Check enable_loyalty_point_program: DF.Check + enable_overdue_billing_threshold: DF.Check enable_party_matching: DF.Check enable_subscription: DF.Check exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"] @@ -96,6 +98,7 @@ class AccountsSettings(Document): receivable_payable_remarks_length: DF.Int reconciliation_queue_size: DF.Int repost_allowed_types: DF.Table[RepostAllowedTypes] + role_allowed_to_bypass_overdue_billing: DF.Link | None role_allowed_to_over_bill: DF.Link | None role_to_notify_on_depreciation_failure: DF.Link | None role_to_override_stop_action: DF.Link | None @@ -151,6 +154,10 @@ class AccountsSettings(Document): toggle_subscription_sections(not self.enable_subscription) clear_cache = True + if old_doc.enable_overdue_billing_threshold != self.enable_overdue_billing_threshold: + toggle_overdue_billing_threshold_field(not self.enable_overdue_billing_threshold) + clear_cache = True + if clear_cache: frappe.clear_cache() @@ -242,6 +249,10 @@ def toggle_subscription_sections(hide): create_property_setter_for_hiding_field(doctype, "subscription_section", hide) +def toggle_overdue_billing_threshold_field(hide): + create_property_setter_for_hiding_field("Customer Credit Limit", "overdue_billing_threshold", hide) + + def create_property_setter_for_hiding_field(doctype, field_name, hide): make_property_setter( doctype, diff --git a/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.json b/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.json index c34b21f7a91..d7b68b42860 100644 --- a/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.json +++ b/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.json @@ -54,7 +54,6 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Closing Balance", - "non_negative": 1, "options": "currency" }, { @@ -191,7 +190,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-05-08 17:55:25.615942", + "modified": "2026-07-09 17:55:25.615942", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Statement Import Log", diff --git a/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py b/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py index 9441ddc429d..468bce0e1fd 100644 --- a/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py +++ b/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py @@ -557,7 +557,7 @@ class BankStatementImportLog(Document): docname=self.name, ) - if self.closing_balance and self.closing_balance > 0 and self.end_date: + if self.closing_balance is not None and self.end_date: set_closing_balance_as_per_statement( self.bank_account, frappe.utils.getdate(self.end_date), self.closing_balance ) diff --git a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py index b7a84f25f11..fcca5db8197 100644 --- a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py +++ b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py @@ -220,6 +220,7 @@ def build_forest(data): for row in data: account_name, parent_account, account_number, parent_account_number = row[0:4] if account_number: + account_number = cstr(account_number).strip() account_name = f"{account_number} - {account_name}" if parent_account_number: parent_account_number = cstr(parent_account_number).strip() diff --git a/erpnext/accounts/doctype/dunning/dunning.js b/erpnext/accounts/doctype/dunning/dunning.js index cd928a414c1..69458652761 100644 --- a/erpnext/accounts/doctype/dunning/dunning.js +++ b/erpnext/accounts/doctype/dunning/dunning.js @@ -169,23 +169,10 @@ frappe.ui.form.on("Dunning", { }, get_dunning_letter_text: function (frm) { if (frm.doc.dunning_type) { - frappe.call({ - method: "erpnext.accounts.doctype.dunning.dunning.get_dunning_letter_text", - args: { - dunning_type: frm.doc.dunning_type, - language: frm.doc.language, - doc: frm.doc, - }, - callback: function (r) { - if (r.message) { - frm.set_value("body_text", r.message.body_text); - frm.set_value("closing_text", r.message.closing_text); - frm.set_value("language", r.message.language); - } else { - frm.set_value("body_text", ""); - frm.set_value("closing_text", ""); - } - }, + frm.call("get_dunning_letter_text").then((r) => { + if (!r.exc) { + frm.refresh_fields(); + } }); } }, diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index 2a4bd381729..dbe8ebcbcd2 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -163,6 +163,46 @@ class Dunning(AccountsController): "Serial and Batch Bundle", ] + @frappe.whitelist() + def get_dunning_letter_text(self): + DOCTYPE = "Dunning Letter Text" + FIELDS = ["body_text", "closing_text", "language"] + + if not self.dunning_type: + return + + filters = {"parent": self.dunning_type, "is_default_language": 1} + + if self.language: + filters.pop("is_default_language") + filters["language"] = self.language + + letter_text = frappe.db.get_value(DOCTYPE, filters, FIELDS, as_dict=True) + + if not letter_text: + msg = ( + _("Dunning Letter for Dunning Type {0} in language '{1}' not found.").format( + frappe.bold(self.dunning_type), frappe.bold(self.language) + ) + if self.language + else _("Dunning Letter for Dunning Type {0} not found.").format( + frappe.bold(self.dunning_type) + ) + ) + frappe.msgprint(msg, alert=True, indicator="yellow") + + self.body_text = ( + frappe.render_template(letter_text.body_text, self.as_dict(), restrict_globals=True) + if letter_text + else None + ) + self.closing_text = ( + frappe.render_template(letter_text.closing_text, self.as_dict(), restrict_globals=True) + if letter_text + else None + ) + self.language = letter_text.language if letter_text else self.language + def update_linked_dunnings(doc, previous_outstanding_amount): if ( @@ -241,34 +281,3 @@ def get_linked_dunnings_as_per_state(sales_invoice, state): & (overdue_payment.sales_invoice == sales_invoice) ) ).run(as_dict=True) - - -@frappe.whitelist() -def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str | None = None) -> dict: - DOCTYPE = "Dunning Letter Text" - FIELDS = ["body_text", "closing_text", "language"] - - doc = frappe.parse_json(doc) - - if not language: - language = doc.get("language") - - letter_text = None - if language: - letter_text = frappe.db.get_value( - DOCTYPE, {"parent": dunning_type, "language": language}, FIELDS, as_dict=1 - ) - - if not letter_text: - letter_text = frappe.db.get_value( - DOCTYPE, {"parent": dunning_type, "is_default_language": 1}, FIELDS, as_dict=1 - ) - - if not letter_text: - return {} - - return { - "body_text": frappe.render_template(letter_text.body_text, doc), - "closing_text": frappe.render_template(letter_text.closing_text, doc), - "language": letter_text.language, - } diff --git a/erpnext/accounts/doctype/dunning_type/dunning_type.py b/erpnext/accounts/doctype/dunning_type/dunning_type.py index 77f2e004e3d..f267ee5b9a1 100644 --- a/erpnext/accounts/doctype/dunning_type/dunning_type.py +++ b/erpnext/accounts/doctype/dunning_type/dunning_type.py @@ -3,7 +3,10 @@ import frappe +from frappe import _ from frappe.model.document import Document +from frappe.utils import comma_and +from frappe.utils.jinja import validate_template class DunningType(Document): @@ -30,3 +33,134 @@ class DunningType(Document): def autoname(self): company_abbr = frappe.get_value("Company", self.company, "abbr") self.name = f"{self.dunning_type} - {company_abbr}" + + def validate(self): + self.validate_dunning_letter_text() + self.validate_income_account() + self.validate_cost_center() + self.set_default_dunning_type() + + def validate_dunning_letter_text(self): + self.validate_languages() + self.validate_is_default_language() + self.validate_dunning_letter_text_templates() + + def validate_income_account(self): + if not self.income_account: + return + + account = frappe.get_cached_doc("Account", self.income_account) + + msg = [] + if account.company != self.company: + msg.append( + _( + "{0} doesn't belong to Company {1}. Please select an Income Account that belongs to Company {1}." + ).format(frappe.bold(self.income_account), frappe.bold(self.company)) + ) + + if account.disabled: + msg.append( + _("{0} is disabled. Please select a valid Income Account.").format( + frappe.bold(self.income_account) + ) + ) + + if account.root_type != "Income": + msg.append( + _("{0} is not an Income Account. Please select a valid Income Account.").format( + frappe.bold(self.income_account) + ) + ) + + if account.is_group: + msg.append( + _("{0} is a group account. Please select a non-group Income Account.").format( + frappe.bold(self.income_account) + ) + ) + + if msg: + frappe.msgprint( + msg, + title=_("Income Account Validation Error"), + as_list=True, + raise_exception=frappe.ValidationError, + ) + + def validate_cost_center(self): + if not self.cost_center: + return + + cost_center = frappe.get_cached_doc("Cost Center", self.cost_center) + + msg = [] + if cost_center.company != self.company: + msg.append( + _( + "{0} doesn't belong to Company {1}. Please select a Cost Center that belongs to Company {1}." + ).format(frappe.bold(self.cost_center), frappe.bold(self.company)) + ) + + if cost_center.disabled: + msg.append( + _("{0} is disabled. Please select an enabled Cost Center.").format( + frappe.bold(self.cost_center) + ) + ) + + if cost_center.is_group: + msg.append( + _("{0} is a group Cost Center. Please select a non-group Cost Center.").format( + frappe.bold(self.cost_center) + ) + ) + + if msg: + frappe.msgprint( + msg, + title=_("Cost Center Validation Error"), + as_list=True, + raise_exception=frappe.ValidationError, + ) + + def validate_languages(self): + languages = [d.language for d in self.dunning_letter_text] + + if len(languages) == len(set(languages)): + return + + frappe.throw(_("Duplicate languages found on Dunning Letter Text. Keep only one of them.")) + + def validate_is_default_language(self): + is_default_language_list = [ + d.language for d in self.dunning_letter_text if d.is_default_language == 1 + ] + + if len(is_default_language_list) <= 1: + return + + frappe.throw( + _("{0} languages are marked as default languages. Please select only one of them.").format( + comma_and(is_default_language_list, add_quotes=True) + ) + ) + + def validate_dunning_letter_text_templates(self): + for d in self.dunning_letter_text: + if d.body_text: + validate_template(d.body_text, restrict_globals=True) + + if d.closing_text: + validate_template(d.closing_text, restrict_globals=True) + + def set_default_dunning_type(self): + if self.is_default != 1: + return + + frappe.db.set_value( + "Dunning Type", + {"company": self.company, "is_default": 1, "name": ["!=", self.name]}, + "is_default", + 0, + ) diff --git a/erpnext/accounts/doctype/dunning_type/test_dunning_type.py b/erpnext/accounts/doctype/dunning_type/test_dunning_type.py index 1e58e56570b..94c30fe089b 100644 --- a/erpnext/accounts/doctype/dunning_type/test_dunning_type.py +++ b/erpnext/accounts/doctype/dunning_type/test_dunning_type.py @@ -1,9 +1,200 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe + +import frappe from erpnext.tests.utils import ERPNextTestSuite +def make_dunning_type(dunning_type, company="_Test Company", **kwargs): + doc = frappe.new_doc("Dunning Type") + doc.dunning_type = dunning_type + doc.company = company + doc.dunning_fee = kwargs.get("dunning_fee", 100) + doc.rate_of_interest = kwargs.get("rate_of_interest", 5) + doc.is_default = kwargs.get("is_default", 0) + + if "income_account" in kwargs: + doc.income_account = kwargs["income_account"] + elif kwargs.get("income_account") is not False: + doc.income_account = "Sales - _TC" if company == "_Test Company" else "Sales - _TC1" + + if "cost_center" in kwargs: + doc.cost_center = kwargs["cost_center"] + elif kwargs.get("cost_center") is not False: + doc.cost_center = "Main - _TC" if company == "_Test Company" else "Main - _TC1" + + for row in kwargs.get("dunning_letter_text", [{"language": "en", "body_text": "Test body"}]): + doc.append("dunning_letter_text", row) + + return doc + + class TestDunningType(ERPNextTestSuite): - pass + def test_income_account_must_belong_to_company(self): + doc = make_dunning_type("_Test Dunning Wrong Company Account", income_account="Sales - _TC1") + self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert) + + def test_income_account_must_not_be_disabled(self): + disabled_account = frappe.get_doc( + { + "doctype": "Account", + "account_name": "_Test Disabled Income Account", + "parent_account": "Direct Income - _TC", + "company": "_Test Company", + "account_type": "Income Account", + "disabled": 1, + } + ).insert() + + doc = make_dunning_type("_Test Dunning Disabled Account", income_account=disabled_account.name) + self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert) + + def test_income_account_must_be_income_type(self): + doc = make_dunning_type("_Test Dunning Non Income Account", income_account="Debtors - _TC") + self.assertRaisesRegex(frappe.ValidationError, "is not an Income Account", doc.insert) + + def test_income_account_must_not_be_group(self): + doc = make_dunning_type("_Test Dunning Group Account", income_account="Income - _TC") + self.assertRaisesRegex(frappe.ValidationError, "is a group account", doc.insert) + + def test_income_account_is_optional(self): + doc = make_dunning_type("_Test Dunning No Income Account", income_account=False) + doc.insert() + self.assertFalse(doc.income_account) + + def test_valid_income_account_passes(self): + doc = make_dunning_type("_Test Dunning Valid Income Account", income_account="Sales - _TC") + doc.insert() + self.assertEqual(doc.income_account, "Sales - _TC") + + def test_cost_center_must_belong_to_company(self): + doc = make_dunning_type("_Test Dunning Wrong Company CC", cost_center="Main - _TC1") + self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert) + + def test_cost_center_must_not_be_disabled(self): + disabled_cc = frappe.get_doc( + { + "doctype": "Cost Center", + "cost_center_name": "_Test Disabled Cost Center", + "parent_cost_center": "_Test Company - _TC", + "company": "_Test Company", + "disabled": 1, + } + ).insert() + + doc = make_dunning_type("_Test Dunning Disabled CC", cost_center=disabled_cc.name) + self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert) + + def test_cost_center_must_not_be_group(self): + doc = make_dunning_type("_Test Dunning Group CC", cost_center="_Test Company - _TC") + self.assertRaisesRegex(frappe.ValidationError, "is a group Cost Center", doc.insert) + + def test_cost_center_is_optional(self): + doc = make_dunning_type("_Test Dunning No CC", cost_center=False) + doc.insert() + self.assertFalse(doc.cost_center) + + def test_valid_cost_center_passes(self): + doc = make_dunning_type("_Test Dunning Valid CC", cost_center="Main - _TC") + doc.insert() + self.assertEqual(doc.cost_center, "Main - _TC") + + def test_duplicate_languages_not_allowed(self): + doc = make_dunning_type( + "_Test Dunning Duplicate Language", + dunning_letter_text=[ + {"language": "en", "body_text": "Body one"}, + {"language": "en", "body_text": "Body two"}, + ], + ) + self.assertRaisesRegex(frappe.ValidationError, "Duplicate languages found", doc.insert) + + def test_unique_languages_allowed(self): + doc = make_dunning_type( + "_Test Dunning Unique Languages", + dunning_letter_text=[ + {"language": "en", "body_text": "Body one"}, + {"language": "de", "body_text": "Body two"}, + ], + ) + doc.insert() + self.assertEqual(len(doc.dunning_letter_text), 2) + + def test_only_one_default_language_allowed(self): + doc = make_dunning_type( + "_Test Dunning Multiple Default Language", + dunning_letter_text=[ + {"language": "en", "body_text": "Body one", "is_default_language": 1}, + {"language": "de", "body_text": "Body two", "is_default_language": 1}, + ], + ) + self.assertRaisesRegex( + frappe.ValidationError, "languages are marked as default languages", doc.insert + ) + + def test_single_default_language_allowed(self): + doc = make_dunning_type( + "_Test Dunning Single Default Language", + dunning_letter_text=[ + {"language": "en", "body_text": "Body one", "is_default_language": 1}, + {"language": "de", "body_text": "Body two", "is_default_language": 0}, + ], + ) + doc.insert() + self.assertEqual(doc.dunning_letter_text[0].is_default_language, 1) + + def test_invalid_jinja_template_in_body_text_raises(self): + doc = make_dunning_type( + "_Test Dunning Invalid Body Template", + dunning_letter_text=[{"language": "en", "body_text": "{{ unclosed"}], + ) + self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert) + + def test_invalid_jinja_template_in_closing_text_raises(self): + doc = make_dunning_type( + "_Test Dunning Invalid Closing Template", + dunning_letter_text=[ + {"language": "en", "body_text": "Valid body", "closing_text": "{{ unclosed"} + ], + ) + self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert) + + def test_valid_jinja_template_passes(self): + doc = make_dunning_type( + "_Test Dunning Valid Template", + dunning_letter_text=[ + { + "language": "en", + "body_text": "Outstanding amount is {{ outstanding_amount }}", + "closing_text": "Regards, {{ company }}", + } + ], + ) + doc.insert() + self.assertTrue(doc.name) + + def test_set_default_dunning_type_unsets_previous_default(self): + first = make_dunning_type("_Test Dunning Default One", is_default=1) + first.insert() + self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 1) + + second = make_dunning_type("_Test Dunning Default Two", is_default=1) + second.insert() + + self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 0) + self.assertEqual(frappe.db.get_value("Dunning Type", second.name, "is_default"), 1) + + def test_set_default_dunning_type_scoped_per_company(self): + company_1 = make_dunning_type("_Test Dunning Default Co1", is_default=1) + company_1.insert() + + company_2 = make_dunning_type( + "_Test Dunning Default Co2", + company="_Test Company 1", + is_default=1, + ) + company_2.insert() + + self.assertEqual(frappe.db.get_value("Dunning Type", company_1.name, "is_default"), 1) + self.assertEqual(frappe.db.get_value("Dunning Type", company_2.name, "is_default"), 1) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js index 2637e49d00a..fac8b582a22 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js @@ -22,17 +22,27 @@ frappe.ui.form.on("Exchange Rate Revaluation", { refresh: function (frm) { if (frm.doc.docstatus == 1) { frappe.call({ - method: "check_journal_entry_condition", + method: "check_journal_and_reversal", doc: frm.doc, callback: function (r) { if (r.message) { - frm.add_custom_button( - __("Journal Entries"), - function () { - return frm.events.make_jv(frm); - }, - __("Create") - ); + if (!r.message.journals_posted) { + frm.add_custom_button( + __("Journal Entries"), + function () { + return frm.events.make_jv(frm); + }, + __("Create") + ); + } else if (!r.message.reversals_posted) { + frm.add_custom_button( + __("Reversal Journal Entries"), + function () { + return frm.events.make_reverse_journal(frm); + }, + __("Create") + ); + } } }, }); @@ -100,6 +110,14 @@ frappe.ui.form.on("Exchange Rate Revaluation", { }, }); }, + make_reverse_journal: function (frm) { + frappe.call({ + method: "make_reverse_journal", + doc: frm.doc, + freeze: true, + freeze_message: __("Reversing Journals..."), + }); + }, }); frappe.ui.form.on("Exchange Rate Revaluation Account", { diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index 0ed30eaee52..84ba411c97f 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -9,7 +9,7 @@ from frappe.model.document import Document from frappe.model.meta import get_field_precision from frappe.query_builder import Criterion, Order from frappe.query_builder.functions import Max, NullIf, Sum -from frappe.utils import flt, get_link_to_form +from frappe.utils import flt, get_link_to_form, nowdate import erpnext from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on @@ -91,25 +91,31 @@ class ExchangeRateRevaluation(Document): ) def on_cancel(self): - self.ignore_linked_doctypes = "GL Entry" + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] @frappe.whitelist() - def check_journal_entry_condition(self): + def check_journal_and_reversal(self): exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account() + journals_posted = False + reversals_posted = False + + je = qb.DocType("Journal Entry") jea = qb.DocType("Journal Entry Account") journals = ( - qb.from_(jea) - .select(jea.parent) + qb.from_(je) + .join(jea) + .on(je.name == jea.parent) + .select(je.name) .distinct() .where( (jea.reference_type == "Exchange Rate Revaluation") & (jea.reference_name == self.name) & (jea.docstatus == 1) + & (je.reversal_of.isnull()) # omit journals that have reversals ) - .run() + .run(pluck="name") ) - if journals: gle = qb.DocType("GL Entry") total_amt = ( @@ -124,12 +130,31 @@ class ExchangeRateRevaluation(Document): .run() ) - if total_amt and total_amt[0][0] != self.total_gain_loss: - return True + if total_amt and total_amt[0][0] == self.total_gain_loss: + journals_posted = True else: - return False + journals_posted = False - return True + # reverse journals + reverse_journals = ( + qb.from_(je) + .join(jea) + .on(je.name == jea.parent) + .select(je.name) + .where( + (jea.reference_type == "Exchange Rate Revaluation") + & (jea.reference_name == self.name) + & (jea.docstatus == 1) + & (je.reversal_of.notnull()) + ) + .run(pluck="name") + ) + if reverse_journals: + reversals_posted = True + else: + reversals_posted = False + + return {"journals_posted": journals_posted, "reversals_posted": reversals_posted} def fetch_and_calculate_accounts_data(self): accounts = self.get_accounts_data() @@ -347,6 +372,7 @@ class ExchangeRateRevaluation(Document): @frappe.whitelist() def make_jv_entries(self): + frappe.has_permission("Journal Entry", "write", throw=True) zero_balance_jv = self.make_jv_for_zero_balance() if zero_balance_jv: frappe.msgprint( @@ -575,6 +601,38 @@ class ExchangeRateRevaluation(Document): journal_entry.save() return journal_entry + @frappe.whitelist() + def make_reverse_journal(self): + frappe.has_permission("Journal Entry", "write", throw=True) + je = qb.DocType("Journal Entry") + jea = qb.DocType("Journal Entry Account") + journals = ( + qb.from_(je) + .join(jea) + .on(je.name == jea.parent) + .select(je.name) + .distinct() + .where( + (jea.reference_type == "Exchange Rate Revaluation") + & (jea.reference_name == self.name) + & (jea.docstatus == 1) + & (je.reversal_of.isnull()) # omit journals that have reversals + ) + .run(pluck="name") + ) + if journals: + from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry + + for x in journals: + reversal = make_reverse_journal_entry(x) + reversal.posting_date = nowdate() + reversal.submit() + frappe.msgprint( + _("Revaluation journal for {0} has been created: {1}").format( + frappe.bold(x), get_link_to_form("Journal Entry", reversal.name) + ) + ) + def calculate_exchange_rate_using_last_gle(company, account, party_type, party): """ diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py index e794311c2cd..3e5b08d069d 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py @@ -132,7 +132,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin): err = err.save().submit() # Create JV for ERR - self.assertTrue(err.check_journal_entry_condition()) + ret = err.check_journal_and_reversal() + self.assertFalse(ret.get("journals_posted")) err_journals = err.make_jv_entries() je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) je = je.submit() @@ -221,7 +222,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin): err = err.save().submit() # Create JV for ERR - self.assertTrue(err.check_journal_entry_condition()) + ret = err.check_journal_and_reversal() + self.assertFalse(ret.get("journals_posted")) err_journals = err.make_jv_entries() je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) je = je.submit() @@ -299,6 +301,91 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin): for key, _val in expected_data.items(): self.assertEqual(expected_data.get(key), account_details.get(key)) + @ERPNextTestSuite.change_settings( + "Accounts Settings", + {"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0}, + ) + def test_05_revaluation_journal_reversal(self): + """ + Test reversing of revaluation journals + """ + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debtors_usd, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + price_list_rate=100, + do_not_submit=1, + ) + si.currency = "USD" + si.conversion_rate = 80 + si.save().submit() + + err = frappe.new_doc("Exchange Rate Revaluation") + err.company = self.company + err.posting_date = today() + err.fetch_and_calculate_accounts_data() + self.assertEqual(len(err.accounts), 1) + err.save().submit() + + gain_loss_account = err.get_for_unrealized_gain_loss_account() + usd_account = err.accounts[0].account + old_balance = err.accounts[0].balance_in_base_currency + new_balance = err.accounts[0].new_balance_in_base_currency + total_gain_loss = err.total_gain_loss + + # Create JV for ERR + ret = err.check_journal_and_reversal() + self.assertFalse(ret.get("journals_posted")) + err_journals = err.make_jv_entries() + je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv")) + je = je.submit() + + je.reload() + self.assertEqual(je.voucher_type, "Exchange Rate Revaluation") + self.assertEqual(len(je.accounts), 3) + # A gain is credited to the gain/loss account, a loss is debited. The current + # exchange rate (from master data) may sit either side of the booked rate, so + # derive the column from the sign instead of assuming a gain. + gain_loss_debit = abs(total_gain_loss) if total_gain_loss < 0 else 0.0 + gain_loss_credit = total_gain_loss if total_gain_loss > 0 else 0.0 + expected = [ + (usd_account, new_balance, 0.0, 100.0, 0.0), + (usd_account, 0.0, old_balance, 0.0, 100.0), + (gain_loss_account, gain_loss_debit, gain_loss_credit, gain_loss_debit, gain_loss_credit), + ] + actual = [] + for acc in je.accounts: + actual.append( + ( + acc.account, + acc.debit, + acc.credit, + acc.debit_in_account_currency, + acc.credit_in_account_currency, + ) + ) + self.assertEqual(expected, actual) + + # Assert reversals are not posted + ret = err.check_journal_and_reversal() + self.assertTrue(ret.get("journals_posted")) + self.assertFalse(ret.get("reversals_posted")) + + err.make_reverse_journal() + ret = err.check_journal_and_reversal() + self.assertTrue(ret.get("journals_posted")) + self.assertTrue(ret.get("reversals_posted")) + + reverse_jv = frappe.db.get_all( + "Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name" + ) + self.assertIsNotNone(reverse_jv) + class TestExchangeRateRevaluationValidation(ERPNextTestSuite): """Validation and gain/loss calculation paths, exercised on the document directly diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js index 6ea0df946f2..1738beb3630 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js @@ -1,7 +1,10 @@ frappe.listview_settings["Journal Entry"] = { - add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark"], + add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark", "reversal_of"], get_indicator: function (doc) { if (doc.docstatus === 1) { + if (doc.reversal_of && doc.voucher_type == "Exchange Rate Revaluation") { + return [__("Reversal Of Exchange Rate Revaluation"), "blue"]; + } return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`]; } }, diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js index 872e939344c..1a7bc328155 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js @@ -24,15 +24,22 @@ frappe.ui.form.on("Opening Invoice Creation Tool", { setTimeout( () => { frm.doc.import_in_progress = false; - frm.clear_table("invoices"); - frm.refresh_fields(); frm.page.clear_indicator(); frm.dashboard.hide_progress(); - if (frm.doc.invoice_type == "Sales") { - frappe.msgprint(__("Opening Sales Invoices have been created.")); + if (!data.errors) { + frm.clear_table("invoices"); + frm.refresh_fields(); + const message = + frm.doc.invoice_type == "Sales" + ? __("Opening Sales Invoice(s) have been created.") + : __("Opening Purchase Invoice(s) have been created."); + frappe.show_alert({ + message: message, + indicator: "green", + }); } else { - frappe.msgprint(__("Opening Purchase Invoices have been created.")); + frm.refresh_fields(); } }, 1500, diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py index a95bc2d4aea..28603721c0c 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py @@ -281,6 +281,7 @@ class OpeningInvoiceCreationTool(Document): def start_import(invoices): errors = 0 names = [] + total = len(invoices) for idx, d in enumerate(invoices): # Scope each invoice to a savepoint so a failure only undoes that invoice. # A plain rollback() would discard the whole transaction — including invoices @@ -289,11 +290,11 @@ def start_import(invoices): # postgres they would be lost). Rolling back to a savepoint keeps both. savepoint = f"opening_invoice_{frappe.generate_hash(length=8)}" frappe.db.savepoint(savepoint) + is_last = idx == total - 1 try: invoice_number = None if d.invoice_number: invoice_number = d.invoice_number - publish(idx, len(invoices), d.doctype) doc = frappe.get_doc(d) doc.flags.ignore_mandatory = True doc.insert(set_name=invoice_number) @@ -301,10 +302,12 @@ def start_import(invoices): if not frappe.in_test: frappe.db.commit() names.append(doc.name) + publish(idx, total, d.doctype, errors=errors if is_last else None) except Exception: errors += 1 frappe.db.rollback(save_point=savepoint) doc.log_error("Opening invoice creation failed") + publish(idx, total, d.doctype, errors=errors if is_last else None) if errors: frappe.msgprint( _("You had {0} errors while creating opening invoices. Check {1} for more details").format( @@ -316,7 +319,7 @@ def start_import(invoices): return names -def publish(index, total, doctype): +def publish(index, total, doctype, errors=None): frappe.publish_realtime( "opening_invoice_creation_progress", dict( @@ -324,6 +327,7 @@ def publish(index, total, doctype): message=_("Creating {} out of {} {}").format(index + 1, total, doctype), count=index + 1, total=total, + errors=errors, ), user=frappe.session.user, ) diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json b/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json index 6448d725de9..7389d0687b6 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json @@ -82,6 +82,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Outstanding Amount", + "options": "Company:company:default_currency", "reqd": 1 }, { @@ -136,7 +137,7 @@ ], "istable": 1, "links": [], - "modified": "2026-04-29 17:08:15.617047", + "modified": "2026-07-02 15:17:11.938499", "modified_by": "Administrator", "module": "Accounts", "name": "Opening Invoice Creation Tool Item", diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index f8ec9ecb805..8a387900cc3 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2530,9 +2530,7 @@ def get_reference_details( exchange_rate = get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date) else: exchange_rate = 1 - outstanding_amount, total_amount = get_outstanding_on_journal_entry( - reference_name, party_type, party - ) + outstanding_amount, total_amount = get_outstanding_on_journal_entry(reference_name, party_type, party) elif reference_doctype == "Payment Entry": if reverse_payment_details := frappe.db.get_all( diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 65502a54a91..d3ce2a0a2f7 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -75,7 +75,10 @@ class PaymentReconciliation(Document): self.accounting_dimension_filter_conditions = [] self.ple_posting_date_filter = [] self.dimensions = get_dimensions(with_cost_center_and_project=True)[0] - self.user_permissions = get_user_permissions(frappe.session.user) + + @property + def user_permissions(self): + return get_user_permissions(frappe.session.user) def load_from_db(self): # 'modified' attribute is required for `run_doc_method` to work properly. diff --git a/erpnext/accounts/doctype/payment_reference/payment_reference.json b/erpnext/accounts/doctype/payment_reference/payment_reference.json index a1adb181d35..4e1e0ac22e3 100644 --- a/erpnext/accounts/doctype/payment_reference/payment_reference.json +++ b/erpnext/accounts/doctype/payment_reference/payment_reference.json @@ -14,7 +14,8 @@ "section_break_mjlv", "due_date", "column_break_qghl", - "amount" + "amount", + "currency" ], "fields": [ { @@ -55,8 +56,18 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Amount", + "options": "currency", "precision": "2" }, + { + "fieldname": "currency", + "fieldtype": "Link", + "hidden": 1, + "label": "Currency", + "options": "Currency", + "print_hide": 1, + "read_only": 1 + }, { "fieldname": "column_break_lnjp", "fieldtype": "Column Break" @@ -74,7 +85,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-01-19 02:21:36.455830", + "modified": "2026-07-11 00:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reference", diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 5b6a56e69c3..97829eb6dc3 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -542,6 +542,7 @@ class PaymentRequest(Document): bank_amount=bank_amount, created_from_payment_request=True, ) + payment_entry.set_missing_ref_details(force=True) payment_entry.update( { @@ -942,6 +943,7 @@ def set_payment_references(payment_schedules): "description": row.get("description"), "due_date": row.get("due_date"), "amount": row.get("payment_amount"), + "currency": row.get("currency"), } ) diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index f09b9b6a626..440933360d1 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -774,6 +774,22 @@ class TestPaymentRequest(ERPNextTestSuite): pi.load_from_db() self.assertEqual(pr_2.grand_total, pi.outstanding_amount) + def test_payment_entry_reference_details_fetched_from_invoice(self): + pi = make_purchase_invoice(currency="INR", qty=1, rate=94500) + pi.submit() + + pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1, submit_doc=0, return_doc=1) + pr.grand_total = 94000 + pr.submit() + + pe = pr.create_payment_entry(submit=False) + + self.assertEqual(pe.references[0].reference_name, pi.name) + self.assertEqual(pe.references[0].total_amount, pi.grand_total) + self.assertEqual(pe.references[0].outstanding_amount, pi.outstanding_amount) + self.assertEqual(pe.references[0].allocated_amount, 94000) + self.assertEqual(pe.paid_amount, 94000) + def test_consider_journal_entry_and_return_invoice(self): from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry diff --git a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json index afab0d66c96..0169b282b9b 100644 --- a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json +++ b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json @@ -89,6 +89,8 @@ "item_tax_rate", "actual_batch_qty", "actual_qty", + "serial_batch_entries_section", + "serial_batch_entries_html", "section_break_tlhi", "serial_no", "column_break_ciit", @@ -859,6 +861,15 @@ "fieldtype": "Check", "label": "Use Serial No / Batch Fields" }, + { + "fieldname": "serial_batch_entries_section", + "fieldtype": "Section Break", + "label": "Serial / Batch Entries" + }, + { + "fieldname": "serial_batch_entries_html", + "fieldtype": "HTML" + }, { "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "section_break_tlhi", @@ -877,7 +888,7 @@ ], "istable": 1, "links": [], - "modified": "2026-06-08 20:00:00.000000", + "modified": "2026-07-18 10:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice Item", diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index 18afcf445ce..dff423b36b4 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -100,9 +100,9 @@ class ProcessStatementOfAccounts(Document): if not self.pdf_name: self.pdf_name = "{{ customer.customer_name }}" - validate_template(self.subject) - validate_template(self.body) - validate_template(self.pdf_name) + validate_template(self.subject, restrict_globals=True) + validate_template(self.body, restrict_globals=True) + validate_template(self.pdf_name, restrict_globals=True) if not self.customers: frappe.throw(_("Customers not selected.")) @@ -421,7 +421,6 @@ def get_context(customer, doc): return { "doc": template_doc, "customer": frappe.get_doc("Customer", customer), - "frappe": frappe.utils, } @@ -532,15 +531,15 @@ def send_emails(document_name: str, from_scheduler: bool = False, posting_date: if report: for customer, report_pdf in report.items(): context = get_context(customer, doc) - filename = frappe.render_template(doc.pdf_name, context) + filename = frappe.render_template(doc.pdf_name, context, restrict_globals=True) attachments = [{"fname": filename + ".pdf", "fcontent": report_pdf}] recipients, cc = get_recipients_and_cc(customer, doc) if not recipients: continue - subject = frappe.render_template(doc.subject, context) - message = frappe.render_template(doc.body, context) + subject = frappe.render_template(doc.subject, context, restrict_globals=True) + message = frappe.render_template(doc.body, context, restrict_globals=True) if doc.sender: sender_email = frappe.db.get_value("Email Account", doc.sender, "email_id") diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index fc693b57d84..f4766ef7413 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -1396,8 +1396,10 @@ "fetch_from": "supplier.represents_company", "fieldname": "represents_company", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Represents Company", - "options": "Company" + "options": "Company", + "read_only": 1 }, { "depends_on": "eval:doc.update_stock && doc.is_internal_supplier", @@ -1692,7 +1694,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2026-06-13 18:36:46.704623", + "modified": "2026-07-12 23:54:21.263951", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 17afc03dde1..e60d3f4614c 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -472,7 +472,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin): pr = frappe.new_doc("Purchase Receipt") pr.currency = "USD" pr.company = "_Test Company with perpetual inventory" - pr.conversion_rate = (70,) + pr.conversion_rate = 80 pr.supplier = "_Test Supplier USD" pr.append( "items", @@ -491,7 +491,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin): # Createing purchase invoice against Purchase Receipt pi = create_purchase_invoice(pr.name) - pi.conversion_rate = 80 + pi.conversion_rate = 70 pi.credit_to = "_Test Payable USD - TCP1" pi.insert() pi.submit() diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json index 5269fec916c..c5de538b897 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json @@ -75,6 +75,10 @@ "quality_inspection", "rejected_warehouse", "rejected_serial_and_batch_bundle", + "serial_batch_entries_section", + "serial_batch_entries_html", + "rejected_serial_batch_entries_section", + "rejected_serial_batch_entries_html", "section_break_rqbe", "serial_no", "rejected_serial_no", @@ -941,6 +945,24 @@ "label": "Use Serial No / Batch Fields", "print_hide": 1 }, + { + "fieldname": "serial_batch_entries_section", + "fieldtype": "Section Break", + "label": "Serial / Batch Entries" + }, + { + "fieldname": "serial_batch_entries_html", + "fieldtype": "HTML" + }, + { + "fieldname": "rejected_serial_batch_entries_section", + "fieldtype": "Section Break", + "label": "Rejected Serial / Batch Entries" + }, + { + "fieldname": "rejected_serial_batch_entries_html", + "fieldtype": "HTML" + }, { "depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1 && parent.update_stock === 1", "fieldname": "section_break_rqbe", @@ -1010,7 +1032,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-06-08 21:00:00.000000", + "modified": "2026-07-18 10:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice Item", diff --git a/erpnext/accounts/doctype/sales_invoice/mapper.py b/erpnext/accounts/doctype/sales_invoice/mapper.py index 46ce4753a85..372f4f8dc52 100644 --- a/erpnext/accounts/doctype/sales_invoice/mapper.py +++ b/erpnext/accounts/doctype/sales_invoice/mapper.py @@ -571,8 +571,6 @@ def create_dunning( source_name: str, target_doc: str | Document | None = None, ignore_permissions: bool = False ): def postprocess_dunning(source, target): - from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text - dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company}) if dunning_type: dunning_type = frappe.get_doc("Dunning Type", dunning_type) @@ -581,14 +579,8 @@ def create_dunning( target.dunning_fee = dunning_type.dunning_fee target.income_account = dunning_type.income_account target.cost_center = dunning_type.cost_center - letter_text = get_dunning_letter_text( - dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language - ) - - if letter_text: - target.body_text = letter_text.get("body_text") - target.closing_text = letter_text.get("closing_text") - target.language = letter_text.get("language") + target.language = source.language + target.get_dunning_letter_text() # update outstanding from doc if source.payment_schedule and len(source.payment_schedule) == 1: diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index b754a4d0f35..e2969ec23ce 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -465,6 +465,7 @@ class SalesInvoice(SellingController): self.update_billing_status_for_zero_amount_refdoc("Delivery Note") self.update_billing_status_for_zero_amount_refdoc("Sales Order") self.check_credit_limit() + self.check_overdue_billing_threshold() if cint(self.is_pos) != 1 and not self.is_return: self.update_against_document_in_jv() @@ -669,6 +670,11 @@ class SalesInvoice(SellingController): if validate_against_credit_limit: check_credit_limit(self.customer, self.company, bypass_credit_limit_check_at_sales_order) + def check_overdue_billing_threshold(self): + from erpnext.selling.doctype.customer.customer import check_overdue_billing_threshold + + check_overdue_billing_threshold(self.customer, self.company) + @frappe.whitelist() def set_missing_values(self, for_validate: bool = False): pos = POSService(self).set_pos_fields(for_validate) diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index 903803aa79f..7fd1ecc1400 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -94,6 +94,8 @@ "incoming_rate", "item_tax_rate", "actual_batch_qty", + "serial_batch_entries_section", + "serial_batch_entries_html", "section_break_eoec", "serial_no", "column_break_ytgd", @@ -954,6 +956,15 @@ "label": "Use Serial No / Batch Fields", "print_hide": 1 }, + { + "fieldname": "serial_batch_entries_section", + "fieldtype": "Section Break", + "label": "Serial / Batch Entries" + }, + { + "fieldname": "serial_batch_entries_html", + "fieldtype": "HTML" + }, { "depends_on": "eval:doc.use_serial_batch_fields === 1 && parent.update_stock === 1", "fieldname": "section_break_eoec", @@ -1055,7 +1066,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-06-08 20:00:00.000000", + "modified": "2026-07-18 10:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", diff --git a/erpnext/accounts/doctype_settings_map/party_account_(standard)/party_account_(standard).json b/erpnext/accounts/doctype_settings_map/party_account_(standard)/party_account_(standard).json new file mode 100644 index 00000000000..625d61d03d3 --- /dev/null +++ b/erpnext/accounts/doctype_settings_map/party_account_(standard)/party_account_(standard).json @@ -0,0 +1,24 @@ +{ + "applies_to_doctype": "Party Account", + "creation": "2026-07-09 16:13:10.010246", + "docstatus": 0, + "doctype": "DocType Settings Map", + "idx": 0, + "is_active": 1, + "is_standard": 1, + "mappings": [ + { + "setting_field": "enable_common_party_accounting", + "settings_doctype": "Accounts Settings" + }, + { + "setting_field": "allow_multi_currency_invoices_against_single_party_account", + "settings_doctype": "Accounts Settings" + } + ], + "modified": "2026-07-09 16:13:49.623613", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Party Account (Standard)", + "owner": "Administrator" +} diff --git a/erpnext/accounts/doctype_settings_map/payment_entry_(standard)/payment_entry_(standard).json b/erpnext/accounts/doctype_settings_map/payment_entry_(standard)/payment_entry_(standard).json new file mode 100644 index 00000000000..5cd47822a3d --- /dev/null +++ b/erpnext/accounts/doctype_settings_map/payment_entry_(standard)/payment_entry_(standard).json @@ -0,0 +1,32 @@ +{ + "applies_to_doctype": "Payment Entry", + "creation": "2026-07-09 15:13:39.598717", + "docstatus": 0, + "doctype": "DocType Settings Map", + "idx": 0, + "is_active": 1, + "is_standard": 1, + "mappings": [ + { + "setting_field": "unlink_payment_on_cancellation_of_invoice", + "settings_doctype": "Accounts Settings" + }, + { + "setting_field": "book_tax_discount_loss", + "settings_doctype": "Accounts Settings" + }, + { + "setting_field": "over_billing_allowance", + "settings_doctype": "Accounts Settings" + }, + { + "setting_field": "merge_similar_account_heads", + "settings_doctype": "Accounts Settings" + } + ], + "modified": "2026-07-10 11:26:57.841200", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Payment Entry (Standard)", + "owner": "Administrator" +} diff --git a/erpnext/accounts/doctype_settings_map/purchase_invoice_(standard)/purchase_invoice_(standard).json b/erpnext/accounts/doctype_settings_map/purchase_invoice_(standard)/purchase_invoice_(standard).json new file mode 100644 index 00000000000..e21c7876f73 --- /dev/null +++ b/erpnext/accounts/doctype_settings_map/purchase_invoice_(standard)/purchase_invoice_(standard).json @@ -0,0 +1,76 @@ +{ + "applies_to_doctype": "Purchase Invoice", + "creation": "2026-07-03 14:20:03.649461", + "docstatus": 0, + "doctype": "DocType Settings Map", + "idx": 0, + "is_active": 1, + "is_standard": 1, + "mappings": [ + { + "setting_field": "allow_to_make_quality_inspection_after_purchase_or_delivery", + "settings_doctype": "Stock Settings" + }, + { + "setting_field": "pr_required", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "po_required", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "project_update_frequency", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "set_landed_cost_based_on_purchase_invoice_rate", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "use_transaction_date_exchange_rate", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "maintain_same_rate", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "maintain_same_rate_action", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "role_to_override_stop_action", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "bill_for_rejected_quantity_in_purchase_invoice", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "unlink_payment_on_cancellation_of_invoice", + "settings_doctype": "Accounts Settings" + }, + { + "setting_field": "check_supplier_invoice_uniqueness", + "settings_doctype": "Accounts Settings" + }, + { + "setting_field": "automatically_fetch_payment_terms", + "settings_doctype": "Accounts Settings" + }, + { + "setting_field": "over_billing_allowance", + "settings_doctype": "Accounts Settings" + }, + { + "setting_field": "role_allowed_to_over_bill", + "settings_doctype": "Accounts Settings" + } + ], + "modified": "2026-07-20 15:56:46.025286", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Purchase Invoice (Standard)", + "owner": "Administrator" +} diff --git a/erpnext/accounts/doctype_settings_map/sales_invoice_(standard)/sales_invoice_(standard).json b/erpnext/accounts/doctype_settings_map/sales_invoice_(standard)/sales_invoice_(standard).json new file mode 100644 index 00000000000..800d9869411 --- /dev/null +++ b/erpnext/accounts/doctype_settings_map/sales_invoice_(standard)/sales_invoice_(standard).json @@ -0,0 +1,68 @@ +{ + "applies_to_doctype": "Sales Invoice", + "creation": "2026-06-30 15:53:13.817029", + "docstatus": 0, + "doctype": "DocType Settings Map", + "idx": 0, + "is_active": 1, + "is_standard": 1, + "mappings": [ + { + "setting_field": "maintain_same_sales_rate", + "settings_doctype": "Selling Settings" + }, + { + "setting_field": "maintain_same_rate_action", + "settings_doctype": "Selling Settings" + }, + { + "setting_field": "role_to_override_stop_action", + "settings_doctype": "Selling Settings" + }, + { + "setting_field": "allow_negative_rates_for_items", + "settings_doctype": "Selling Settings" + }, + { + "setting_field": "sales_update_frequency", + "settings_doctype": "Selling Settings" + }, + { + "setting_field": "dn_required", + "settings_doctype": "Selling Settings" + }, + { + "setting_field": "so_required", + "settings_doctype": "Selling Settings" + }, + { + "setting_field": "allow_to_make_quality_inspection_after_purchase_or_delivery", + "settings_doctype": "Stock Settings" + }, + { + "setting_field": "unlink_payment_on_cancellation_of_invoice", + "settings_doctype": "Accounts Settings" + }, + { + "setting_field": "automatically_fetch_payment_terms", + "settings_doctype": "Accounts Settings" + }, + { + "setting_field": "role_allowed_to_over_bill", + "settings_doctype": "Accounts Settings" + }, + { + "setting_field": "over_billing_allowance", + "settings_doctype": "Accounts Settings" + }, + { + "setting_field": "fetch_timesheet_in_sales_invoice", + "settings_doctype": "Projects Settings" + } + ], + "modified": "2026-07-20 15:32:43.080034", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Sales Invoice (Standard)", + "owner": "Administrator" +} diff --git a/erpnext/accounts/doctype_settings_map/subscription_(standard)/subscription_(standard).json b/erpnext/accounts/doctype_settings_map/subscription_(standard)/subscription_(standard).json new file mode 100644 index 00000000000..0e141f25080 --- /dev/null +++ b/erpnext/accounts/doctype_settings_map/subscription_(standard)/subscription_(standard).json @@ -0,0 +1,24 @@ +{ + "applies_to_doctype": "Subscription", + "creation": "2026-07-09 15:08:44.722645", + "docstatus": 0, + "doctype": "DocType Settings Map", + "idx": 0, + "is_active": 1, + "is_standard": 1, + "mappings": [ + { + "setting_field": "grace_period", + "settings_doctype": "Subscription Settings" + }, + { + "setting_field": "cancel_after_grace", + "settings_doctype": "Subscription Settings" + } + ], + "modified": "2026-07-09 15:08:57.487184", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Subscription (Standard)", + "owner": "Administrator" +} diff --git a/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json b/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json index 28b60e313c4..fbb83c7151f 100644 --- a/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json +++ b/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json @@ -1,6 +1,6 @@ { "align": "Left", - "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t
\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}
\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t{% endif %}\n\t\t\t
\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t
\n\t\t\t\t\t{{ doc.doctype }}\n\t\t\t\t\t{{ doc.name }}\n\t\t\t\t
\n\t\t\t\t{% if website %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", + "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t
\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}
\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t{% endif %}\n\t\t\t
\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") if doc.get(\"company\") else None %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") if doc.get(\"company\") else None %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") if doc.get(\"company\") else None %}\n\n\t\t\t\t
\n\t\t\t\t\t{{ doc.doctype }}\n\t\t\t\t\t{{ doc.name }}\n\t\t\t\t
\n\t\t\t\t{% if website %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", "creation": "2026-05-15 15:21:48.255627", "custom_css": "\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tpadding-right: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\n\t.letter-head td {\n\t\tpadding: 0px !important;\n\t}\n\t.invoice-header {\n\t\twidth: 100%;\n\t}\n\t.logo-cell {\n\t\twidth: 100px;\n\t\ttext-align: center;\n\t\tposition: relative;\n\t}\n\t.logo-container {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t}\n\t.logo-container img {\n\t\tmax-width: 90px;\n\t\tmax-height: 90px;\n\t\tdisplay: inline-block;\n\t\tborder-radius: 15px;\n\t}\n\t.company-details {\n\t\twidth: 40%;\n\t\talign-content: center;\n\t}\n\t.company-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #171717;\n\t\tmargin-bottom: 4px;\n\t}\n\t.invoice-info-cell {\n\t\tfloat: right;\n\t\tvertical-align: top;\n\t}\n\t.invoice-info {\n\t\tmargin-bottom: 2px;\n\t}\n\t.invoice-label {\n\t\tcolor: #7c7c7c;\n\t\tdisplay: inline-block;\n\t\tmargin-right: 5px;\n\t}", "disabled": 0, @@ -16,7 +16,7 @@ "is_default": 0, "letter_head_for": "DocType", "letter_head_name": "Company Letterhead", - "modified": "2026-06-24 17:49:52.350750", + "modified": "2026-07-12 21:11:44.765083", "modified_by": "Administrator", "module": "Accounts", "name": "Company Letterhead", diff --git a/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json b/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json index 67c03298195..dd9035197a2 100644 --- a/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json +++ b/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json @@ -1,6 +1,6 @@ { "align": "Left", - "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}
\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
{{ doc.doctype }}
\n\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Website:\") }}{{ website }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Email:\") }}{{ email }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Contact:\") }}{{ phone_no }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n", + "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}
\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
{{ doc.doctype }}
\n\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Website:\") }}{{ website }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Email:\") }}{{ email }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Contact:\") }}{{ phone_no }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n", "creation": "2026-05-15 15:21:48.373815", "custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}", "disabled": 0, @@ -16,7 +16,7 @@ "is_default": 0, "letter_head_for": "DocType", "letter_head_name": "Company Letterhead - Grey", - "modified": "2026-06-24 18:23:05.120521", + "modified": "2026-07-12 22:03:24.525672", "modified_by": "Administrator", "module": "Accounts", "name": "Company Letterhead - Grey", diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js index 8541e094640..f0bca38d443 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.js +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js @@ -117,8 +117,11 @@ frappe.query_reports["Accounts Payable"] = { { fieldname: "supplier_group", label: __("Supplier Group"), - fieldtype: "Link", + fieldtype: "MultiSelectList", options: "Supplier Group", + get_data: function (txt) { + return frappe.db.get_link_options("Supplier Group", txt); + }, hidden: 1, }, { diff --git a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py index 5b1b567c8d4..b8deb356aa6 100644 --- a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py +++ b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py @@ -166,6 +166,36 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin): self.assertEqual(len(report[1]), 2) self.assertEqual([pi.name, expected_payment_term], [row.voucher_no, row.payment_term]) + def test_supplier_group_filter(self): + pi = self.create_purchase_invoice() + supplier_group = frappe.db.get_value("Supplier", self.supplier, "supplier_group") + other_group = frappe.get_doc( + doctype="Supplier Group", + supplier_group_name="_Test Supplier Group AP", + parent_supplier_group="All Supplier Groups", + ).insert() + + filters = { + "company": self.company, + "party_type": "Supplier", + "report_date": today(), + "range": "30, 60, 90, 120", + "supplier_group": supplier_group, + } + self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]]) + + filters.update({"supplier_group": [other_group.name]}) + self.assertEqual(len(execute(filters)[1]), 0) + + filters.update({"supplier_group": [supplier_group, other_group.name]}) + self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]]) + + filters.update({"supplier_group": ["All Supplier Groups"]}) + self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]]) + + filters.update({"supplier_group": ["_Test Supplier Group Mars"]}) + self.assertRaises(frappe.ValidationError, execute, filters) + def test_project_filter(self): project = frappe.get_doc("Project", {"project_name": "_Test Project"}) diff --git a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js index b05b783a236..72fb564cf9e 100644 --- a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js +++ b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js @@ -100,8 +100,11 @@ frappe.query_reports["Accounts Payable Summary"] = { { fieldname: "supplier_group", label: __("Supplier Group"), - fieldtype: "Link", + fieldtype: "MultiSelectList", options: "Supplier Group", + get_data: function (txt) { + return frappe.db.get_link_options("Supplier Group", txt); + }, }, { fieldname: "based_on_payment_terms", diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js index 3f87acbb407..4a6ef4dd86a 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js @@ -140,8 +140,11 @@ frappe.query_reports["Accounts Receivable"] = { { fieldname: "territory", label: __("Territory"), - fieldtype: "Link", + fieldtype: "MultiSelectList", options: "Territory", + get_data: function (txt) { + return frappe.db.get_link_options("Territory", txt); + }, }, { fieldname: "group_by_party", diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 9b5fbc1b606..bb07fee6c66 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -264,10 +264,12 @@ class ReceivablePayableReport: # Build and use a separate row for Employee Advances. # This allows Payments or Journals made against Emp Advance to be processed. - if ( - not row - and ple.against_voucher_type == "Employee Advance" - and self.filters.handle_employee_advances + if not row and ( + (ple.against_voucher_type == "Employee Advance" and self.filters.handle_employee_advances) + or ( + ple.against_voucher_type == "Exchange Rate Revaluation" + and self.filters.for_revaluation_journals + ) ): _d = self.build_voucher_dict(ple) _d.voucher_type = ple.against_voucher_type @@ -996,7 +998,13 @@ class ReceivablePayableReport: self.qb_selection_filter.append(self.ple.party.isin(customers)) if self.filters.get("territory"): - self.get_hierarchical_filters("Territory", "territory") + territories = get_nested_set_children("Territory", self.filters.territory) + customers = ( + qb.from_(self.customer) + .select(self.customer.name) + .where(self.customer["territory"].isin(territories)) + ) + self.qb_selection_filter.append(self.ple.party.isin(customers)) if self.filters.get("payment_terms_template"): customer_ptt = self.ple.party.isin( @@ -1026,11 +1034,10 @@ class ReceivablePayableReport: def add_supplier_filters(self): supplier = qb.DocType("Supplier") if self.filters.get("supplier_group"): + groups = get_party_group_with_children("Supplier", self.filters.supplier_group) self.qb_selection_filter.append( self.ple.party.isin( - qb.from_(supplier) - .select(supplier.name) - .where(supplier.supplier_group == self.filters.get("supplier_group")) + qb.from_(supplier).select(supplier.name).where(supplier.supplier_group.isin(groups)) ) ) @@ -1082,16 +1089,6 @@ class ReceivablePayableReport: return ptt - def get_hierarchical_filters(self, doctype, key): - lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"]) - - doc = qb.DocType(doctype) - ple = self.ple - customer = self.customer - groups = qb.from_(doc).select(doc.name).where((doc.lft >= lft) & (doc.rgt <= rgt)) - customers = qb.from_(customer).select(customer.name).where(customer[key].isin(groups)) - self.qb_selection_filter.append(ple.party.isin(customers)) - def add_accounting_dimensions_filters(self): accounting_dimensions = get_accounting_dimensions(as_list=False) @@ -1338,19 +1335,23 @@ def get_party_group_with_children(party, party_groups): if party not in ("Customer", "Supplier"): return [] - group_dtype = f"{party} Group" - if not isinstance(party_groups, list): - party_groups = [d.strip() for d in party_groups.strip().split(",") if d] + return get_nested_set_children(f"{party} Group", party_groups) - all_party_groups = [] - for d in party_groups: - if frappe.db.exists(group_dtype, d): - lft, rgt = frappe.db.get_value(group_dtype, d, ["lft", "rgt"]) - children = frappe.get_all( - group_dtype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name" - ) - all_party_groups += children + +def get_nested_set_children(doctype, values): + if not isinstance(values, list): + values = [d.strip() for d in values.split(",") if d.strip()] + + if not values: + frappe.throw(_("Please select a valid {0}").format(_(doctype))) + + all_values = [] + for d in values: + if frappe.db.exists(doctype, d): + lft, rgt = frappe.db.get_value(doctype, d, ["lft", "rgt"]) + children = frappe.get_all(doctype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name") + all_values += children else: - frappe.throw(_("{0}: {1} does not exist").format(group_dtype, d)) + frappe.throw(_("{0}: {1} does not exist").format(doctype, d)) - return list(set(all_party_groups)) + return list(set(all_values)) diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 6aca094a4e1..a2a953dddda 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -944,6 +944,38 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin): # Assert that the customer group of each row is in the list of customer groups self.assertIn(row.customer_group, cus_groups_list) + def test_territory_filter(self): + self.create_sales_invoice() + territory = frappe.db.get_value("Customer", self.customer, "territory") + + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + "territory": territory, + } + report = execute(filters)[1] + self.assertEqual(len(report), 1) + self.assertEqual( + [100.0, 100.0, territory], [report[0].invoiced, report[0].outstanding, report[0].territory] + ) + + filters.update({"territory": ["_Test Territory United States"]}) + self.assertEqual(len(execute(filters)[1]), 0) + + filters.update({"territory": [territory, "_Test Territory United States"]}) + self.assertEqual(len(execute(filters)[1]), 1) + + frappe.db.set_value("Customer", self.customer, "territory", "_Test Territory Maharashtra") + filters.update({"territory": ["_Test Territory India"]}) + self.assertEqual(len(execute(filters)[1]), 1) + + filters.update({"territory": ["_Test Territory Mars"]}) + self.assertRaises(frappe.ValidationError, execute, filters) + + filters.update({"territory": " "}) + self.assertRaises(frappe.ValidationError, execute, filters) + def test_party_account_filter(self): si1 = self.create_sales_invoice() jane = frappe.get_doc( diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js index 59ce271f7f7..e71638a59e4 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js @@ -106,8 +106,11 @@ frappe.query_reports["Accounts Receivable Summary"] = { { fieldname: "territory", label: __("Territory"), - fieldtype: "Link", + fieldtype: "MultiSelectList", options: "Territory", + get_data: function (txt) { + return frappe.db.get_link_options("Territory", txt); + }, }, { fieldname: "sales_partner", diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index cf4d32416c4..22e1e6854d7 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -422,6 +422,11 @@ def build_comparison_chart_data(filters, columns, data): if not fieldname: continue + # skip the dimension column ("budget_against"), it only matches the + # "budget_" prefix by coincidence and would shift the actual values by one + if fieldname == "budget_against": + continue + if fieldname.startswith("budget_"): budget_fields.append(fieldname) elif fieldname.startswith("actual_"): @@ -433,7 +438,7 @@ def build_comparison_chart_data(filters, columns, data): labels = [ col["label"].replace("Budget", "").strip() for col in columns - if col.get("fieldname", "").startswith("budget_") + if col.get("fieldname", "").startswith("budget_") and col.get("fieldname") != "budget_against" ] budget_values = [0] * len(budget_fields) diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py index 31da6d5c41f..65e1853b83f 100644 --- a/erpnext/accounts/report/cash_flow/cash_flow.py +++ b/erpnext/accounts/report/cash_flow/cash_flow.py @@ -88,6 +88,7 @@ def execute(filters=None): "parent_section": None, "indent": 0.0, "section": cash_flow_section["section_header"], + "currency": company_currency, } ) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index af209a67f25..c600226e9ee 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -227,6 +227,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ ) if total_base_amount else 0, + "currency": filters.currency, } ) ) @@ -269,6 +270,7 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_ "buying_amount": total_buying_amount, "gross_profit": total_gross_profit, "gross_profit_percent": flt(gross_profit_percent, currency_precision), + "currency": filters.currency, } total_row = [total_row.get(col, None) for col in [*group_columns, "currency"]] diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py index 3ab3986b013..b6fc77fd1c4 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -21,11 +21,21 @@ class TDSComputationSummaryReport(TaxWithholdingDetailsReport): AGGREGATE_FIELDS = ("total_amount", "tax_amount") def validate_filters(self): - if self.filters.from_date > self.filters.to_date: + from_date = self.filters.from_date + to_date = self.filters.to_date + if not from_date or not to_date: + frappe.throw( + _("{0} and {1} are mandatory").format( + frappe.bold(_("From Date")), + frappe.bold(_("To Date")), + ) + ) + + if from_date > to_date: frappe.throw(_("From Date must be before To Date")) - from_year = get_fiscal_year(self.filters.from_date)[0] - to_year = get_fiscal_year(self.filters.to_date)[0] + from_year = get_fiscal_year(from_date)[0] + to_year = get_fiscal_year(to_date)[0] if from_year != to_year: frappe.throw(_("From Date and To Date lie in different Fiscal Year")) diff --git a/erpnext/accounts/services/deferred_accounting.py b/erpnext/accounts/services/deferred_accounting.py new file mode 100644 index 00000000000..8465d079955 --- /dev/null +++ b/erpnext/accounts/services/deferred_accounting.py @@ -0,0 +1,57 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Deferred revenue/expense accounting validations.""" + +import frappe +from frappe import _ +from frappe.utils import getdate + +DEFERRED_ACCOUNT_FIELD = { + "Sales Invoice": "deferred_revenue_account", + "Purchase Invoice": "deferred_expense_account", +} + + +class DeferredAccountingService: + def __init__(self, doc): + self.doc = doc + + def validate_income_expense_account(self) -> None: + account_field = DEFERRED_ACCOUNT_FIELD.get(self.doc.doctype) + + for item in self.doc.get("items"): + if not self._is_deferred(item) or item.get(account_field): + continue + + default_account = frappe.get_cached_value("Company", self.doc.company, "default_" + account_field) + if not default_account: + frappe.throw( + _( + "Row #{0}: Please update deferred revenue/expense account in item row or default account in company master" + ).format(item.idx) + ) + item.set(account_field, default_account) + + def validate_start_and_end_date(self) -> None: + for item in self.doc.items: + if not self._is_deferred(item): + continue + + if not (item.service_start_date and item.service_end_date): + frappe.throw( + _("Row #{0}: Service Start and End Date is required for deferred accounting").format( + item.idx + ) + ) + elif getdate(item.service_start_date) > getdate(item.service_end_date): + frappe.throw( + _("Row #{0}: Service Start Date cannot be greater than Service End Date").format(item.idx) + ) + elif getdate(self.doc.posting_date) > getdate(item.service_end_date): + frappe.throw( + _("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(item.idx) + ) + + def _is_deferred(self, item) -> bool: + return bool(item.get("enable_deferred_revenue") or item.get("enable_deferred_expense")) diff --git a/erpnext/accounts/services/payment_schedule.py b/erpnext/accounts/services/payment_schedule.py index d1ff7e91cb7..79cf352cd6c 100644 --- a/erpnext/accounts/services/payment_schedule.py +++ b/erpnext/accounts/services/payment_schedule.py @@ -293,6 +293,39 @@ class PaymentScheduleService: _("Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total") ) + def validate_all_documents_schedule(self) -> None: + if self.doc.doctype in ("Sales Invoice", "Purchase Invoice"): + self.validate_invoice_documents_schedule() + elif self.doc.doctype in ("Quotation", "Purchase Order", "Sales Order"): + self.validate_non_invoice_documents_schedule() + + def validate_invoice_documents_schedule(self) -> None: + doc = self.doc + if ( + doc.is_return + or (doc.doctype == "Purchase Invoice" and doc.is_paid) + or (doc.doctype == "Sales Invoice" and doc.is_pos) + or doc.get("is_opening") == "Yes" + ): + doc.payment_terms_template = "" + doc.payment_schedule = [] + + if doc.is_return: + return + + self.validate_payment_schedule_dates() + self.set_due_date() + self.set_payment_schedule() + if not doc.get("ignore_default_payment_terms_template"): + self.validate_payment_schedule_amount() + doc.validate_due_date() + doc.validate_advance_entries() + + def validate_non_invoice_documents_schedule(self) -> None: + self.set_payment_schedule() + self.validate_payment_schedule_dates() + self.validate_payment_schedule_amount() + def linked_order_has_payment_terms_template(po_or_so, doctype) -> str | None: return frappe.get_value(doctype, po_or_so, "payment_terms_template") diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 9b50751e95a..8ec0c053038 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1427,13 +1427,11 @@ def get_account_balances( def get_account_balances_coa(company: str, include_default_fb_balances: bool = False): company_currency = frappe.get_cached_value("Company", company, "default_currency") - Account = DocType("Account") - account_list = ( - frappe.qb.from_(Account) - .select(Account.name, Account.parent_account, Account.account_currency) - .where(Account.company == company) - .orderby(Account.lft) - .run(as_dict=True) + account_list = frappe.get_list( + "Account", + fields=["name", "parent_account", "account_currency"], + filters={"company": company}, + order_by="lft", ) account_balances_cc = {account.get("name"): 0 for account in account_list} @@ -1443,9 +1441,8 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F GLEntry = DocType("GL Entry") precision = get_currency_precision() get_ledger_balances_query = ( - frappe.qb.from_(GLEntry) + frappe.get_query(GLEntry, fields=[GLEntry.account], ignore_permissions=False) .select( - GLEntry.account, (Sum(Round(GLEntry.debit, precision)) - Sum(Round(GLEntry.credit, precision))).as_("balance"), ( Sum(Round(GLEntry.debit_in_account_currency, precision)) @@ -1455,7 +1452,7 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F .groupby(GLEntry.account) ) - condition_list = [GLEntry.company == company, GLEntry.is_cancelled == 0] + conditions = [GLEntry.company == company, GLEntry.is_cancelled == 0] default_finance_book = None @@ -1463,12 +1460,9 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F default_finance_book = frappe.get_cached_value("Company", company, "default_finance_book") if default_finance_book: - condition_list.append( - (GLEntry.finance_book == default_finance_book) | (GLEntry.finance_book.isnull()) - ) + conditions.append((GLEntry.finance_book == default_finance_book) | (GLEntry.finance_book.isnull())) - for condition in condition_list: - get_ledger_balances_query = get_ledger_balances_query.where(condition) + get_ledger_balances_query = get_ledger_balances_query.where(Criterion.all(conditions)) ledger_balances = get_ledger_balances_query.run(as_dict=True) diff --git a/erpnext/accounts/workspace/accounting/accounting.json b/erpnext/accounts/workspace/accounting/accounting.json new file mode 100644 index 00000000000..e7dcefb59f3 --- /dev/null +++ b/erpnext/accounts/workspace/accounting/accounting.json @@ -0,0 +1,735 @@ +{ + "app": "erpnext", + "charts": [ + { + "chart_name": "Profit and Loss", + "label": "Profit and Loss" + }, + { + "chart_name": "Accounts Receivable Ageing", + "label": "Accounts Receivable Ageing" + }, + { + "chart_name": "Accounts Payable Ageing", + "label": "Accounts Payable Ageing" + }, + { + "chart_name": "Bank Balance", + "label": "Bank Balance" + }, + { + "chart_name": "Budget Variance", + "label": "Budget Variance" + } + ], + "content": "[{\"id\":\"acc_ov_hdr1\",\"type\":\"header\",\"data\":{\"text\":\"Accounting Overview\",\"col\":12}},{\"id\":\"acc_ov_nc01\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Outgoing Bills\",\"col\":3}},{\"id\":\"acc_ov_nc02\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Incoming Bills\",\"col\":3}},{\"id\":\"acc_ov_nc03\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Incoming Payment\",\"col\":3}},{\"id\":\"acc_ov_nc04\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Outgoing Payment\",\"col\":3}},{\"id\":\"acc_ov_ch01\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Profit and Loss\",\"col\":12}},{\"id\":\"acc_ov_ch02\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Accounts Receivable Ageing\",\"col\":6}},{\"id\":\"acc_ov_ch03\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Accounts Payable Ageing\",\"col\":6}},{\"id\":\"acc_ov_ch04\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Bank Balance\",\"col\":6}},{\"id\":\"acc_ov_ch05\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Budget Variance\",\"col\":6}}]", + "creation": "2026-07-14 12:00:00", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "for_user": "", + "hide_custom": 0, + "icon": "landmark", + "idx": 0, + "indicator_color": "green", + "is_hidden": 0, + "label": "Accounting", + "link_type": "DocType", + "links": [], + "modified": "2026-07-14 14:28:55.763394", + "modified_by": "Administrator", + "module": "Accounts", + "module_onboarding": "Accounting Onboarding", + "name": "Accounting", + "number_cards": [ + { + "label": "Outgoing Bills", + "number_card_name": "Total Outgoing Bills" + }, + { + "label": "Incoming Bills", + "number_card_name": "Total Incoming Bills" + }, + { + "label": "Incoming Payment", + "number_card_name": "Total Incoming Payment" + }, + { + "label": "Outgoing Payment", + "number_card_name": "Total Outgoing Payment" + } + ], + "owner": "Administrator", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 4.0, + "shortcuts": [], + "sidebar_items": [ + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "house", + "indent": 0, + "keep_closed": 0, + "label": "Home", + "link_to": "Accounting", + "link_type": "Workspace", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "database", + "indent": 1, + "keep_closed": 0, + "label": "Setup", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Chart of Accounts", + "link_to": "Account", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Chart of Cost Centers", + "link_to": "Cost Center", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Account Category", + "link_to": "Account Category", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Accounting Dimension", + "link_to": "Accounting Dimension", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Currency", + "link_to": "Currency", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Currency Exchange", + "link_to": "Currency Exchange", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Finance Book", + "link_to": "Finance Book", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Mode of Payment", + "link_to": "Mode of Payment", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Payment Term", + "link_to": "Payment Term", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Journal Entry Template", + "link_to": "Journal Entry Template", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Terms and Conditions", + "link_to": "Terms and Conditions", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Company", + "link_to": "Company", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Fiscal Year", + "link_to": "Fiscal Year", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "book-open-check", + "indent": 1, + "keep_closed": 1, + "label": "Opening & Closing", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "COA Importer", + "link_to": "Chart of Accounts Importer", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Opening Invoice Tool", + "link_to": "Opening Invoice Creation Tool", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Accounting Period", + "link_to": "Accounting Period", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "FX Revaluation", + "link_to": "Exchange Rate Revaluation", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Period Closing Voucher", + "link_to": "Period Closing Voucher", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "coins", + "indent": 1, + "keep_closed": 1, + "label": "Taxes", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "panel-bottom-close", + "indent": 0, + "keep_closed": 0, + "label": "Sales Tax Template", + "link_to": "Sales Taxes and Charges Template", + "link_type": "DocType", + "navigate_to_tab": "", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "panel-top-close", + "indent": 0, + "keep_closed": 0, + "label": "Purchase Tax Template", + "link_to": "Purchase Taxes and Charges Template", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "package", + "indent": 0, + "keep_closed": 0, + "label": "Item Tax Template", + "link_to": "Item Tax Template", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "triangle", + "indent": 0, + "keep_closed": 0, + "label": "Tax Category", + "link_to": "Tax Category", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "book-open-text", + "indent": 0, + "keep_closed": 0, + "label": "Tax Rule", + "link_to": "Tax Rule", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "book-text", + "indent": 0, + "keep_closed": 0, + "label": "Tax Withholding Category", + "link_to": "Tax Withholding Category", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Tax Withholding Group", + "link_to": "Tax Withholding Group", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "notebook-text", + "indent": 0, + "keep_closed": 0, + "label": "Deduction Certificate", + "link_to": "Lower Deduction Certificate", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "wallet", + "indent": 1, + "keep_closed": 1, + "label": "Budgeting", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "briefcase-business", + "indent": 0, + "keep_closed": 0, + "label": "Budget", + "link_to": "Budget", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "notepad-text", + "indent": 0, + "keep_closed": 0, + "label": "Cost Center Allocation", + "link_to": "Cost Center Allocation", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "coins", + "indent": 1, + "keep_closed": 1, + "label": "Share Management", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "user", + "indent": 0, + "keep_closed": 0, + "label": "Shareholder", + "link_to": "Shareholder", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "move-horizontal", + "indent": 0, + "keep_closed": 0, + "label": "Share Transfer", + "link_to": "Share Transfer", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "repeat", + "indent": 1, + "keep_closed": 1, + "label": "Subscriptions", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "circle-dollar-sign", + "indent": 0, + "keep_closed": 0, + "label": "Subscription", + "link_to": "Subscription", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "receipt-text", + "indent": 0, + "keep_closed": 0, + "label": "Subscription Plan", + "link_to": "Subscription Plan", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "settings", + "indent": 0, + "keep_closed": 0, + "label": "Subscription Settings", + "link_to": "Subscription Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "sheet", + "indent": 1, + "keep_closed": 1, + "label": "Reports", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "TDS Computation Summary", + "link_to": "TDS Computation Summary", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Tax Withholding Details", + "link_to": "Tax Withholding Details", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "sheet", + "indent": 0, + "keep_closed": 0, + "label": "Budget Variance", + "link_to": "Budget Variance Report", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "list", + "indent": 0, + "keep_closed": 0, + "label": "Share Ledger", + "link_to": "Share Ledger", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "notepad-text", + "indent": 0, + "keep_closed": 0, + "label": "Share Balance", + "link_to": "Share Balance", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "wrench", + "indent": 1, + "keep_closed": 1, + "label": "Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Accounts Settings", + "link_to": "Accounts Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Currency Exchange Settings", + "link_to": "Currency Exchange Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + } + ], + "standard": 1, + "title": "Accounting", + "type": "Workspace" +} diff --git a/erpnext/accounts/workspace/accounts_setup/accounts_setup.json b/erpnext/accounts/workspace/accounts_setup/accounts_setup.json deleted file mode 100644 index 88dd071b131..00000000000 --- a/erpnext/accounts/workspace/accounts_setup/accounts_setup.json +++ /dev/null @@ -1,329 +0,0 @@ -{ - "app": "erpnext", - "charts": [], - "content": "[]", - "creation": "2026-06-14 12:44:31.994274", - "custom_blocks": [], - "docstatus": 0, - "doctype": "Workspace", - "for_user": "", - "hide_custom": 0, - "icon": "database", - "idx": 0, - "indicator_color": "green", - "is_hidden": 0, - "label": "Accounts Setup", - "link_type": "DocType", - "links": [], - "modified": "2026-06-14 13:43:50.138704", - "modified_by": "Administrator", - "module": "Accounts", - "module_onboarding": "Accounting Onboarding", - "name": "Accounts Setup", - "number_cards": [], - "owner": "Administrator", - "public": 1, - "quick_lists": [], - "roles": [], - "sequence_id": 55.0, - "shortcuts": [], - "sidebar_items": [ - { - "child": 0, - "collapsible": 1, - "icon": "database", - "indent": 1, - "keep_closed": 0, - "label": "Setup", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Section Break" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Chart of Accounts", - "link_to": "Account", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Chart of Cost Centers", - "link_to": "Cost Center", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Account Category", - "link_to": "Account Category", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Accounting Dimension", - "link_to": "Accounting Dimension", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Currency", - "link_to": "Currency", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Currency Exchange", - "link_to": "Currency Exchange", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Finance Book", - "link_to": "Finance Book", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Mode of Payment", - "link_to": "Mode of Payment", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Payment Term", - "link_to": "Payment Term", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Journal Entry Template", - "link_to": "Journal Entry Template", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Terms and Conditions", - "link_to": "Terms and Conditions", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Company", - "link_to": "Company", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Fiscal Year", - "link_to": "Fiscal Year", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Sales Taxes", - "link_to": "Sales Taxes and Charges Template", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "lock-keyhole-open", - "indent": 1, - "keep_closed": 0, - "label": "Opening & Closing", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Section Break" - }, - { - "child": 1, - "collapsible": 1, - "icon": "", - "indent": 0, - "keep_closed": 0, - "label": "COA Importer", - "link_to": "Chart of Accounts Importer", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "icon": "", - "indent": 0, - "keep_closed": 0, - "label": "Opening Invoice Tool", - "link_to": "Opening Invoice Creation Tool", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "icon": "", - "indent": 0, - "keep_closed": 0, - "label": "Accounting Period", - "link_to": "Accounting Period", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "icon": "", - "indent": 0, - "keep_closed": 0, - "label": "FX Revaluation", - "link_to": "Exchange Rate Revaluation", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "icon": "", - "indent": 0, - "keep_closed": 0, - "label": "Period Closing Voucher", - "link_to": "Period Closing Voucher", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "settings", - "indent": 1, - "keep_closed": 0, - "label": "Settings", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Section Break" - }, - { - "child": 1, - "collapsible": 1, - "icon": "", - "indent": 0, - "keep_closed": 0, - "label": "Accounts Settings", - "link_to": "Accounts Settings", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Currency Exchange Settings", - "link_to": "Currency Exchange Settings", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - } - ], - "standard": 1, - "title": "Accounts Setup", - "type": "Workspace" -} diff --git a/erpnext/accounts/workspace/banking/banking.json b/erpnext/accounts/workspace/banking/banking.json deleted file mode 100644 index d4ff8487759..00000000000 --- a/erpnext/accounts/workspace/banking/banking.json +++ /dev/null @@ -1,222 +0,0 @@ -{ - "app": "erpnext", - "charts": [], - "content": "[]", - "creation": "2026-06-11 11:51:22.767176", - "custom_blocks": [], - "docstatus": 0, - "doctype": "Workspace", - "for_user": "", - "hide_custom": 0, - "icon": "circle-dollar-sign", - "idx": 0, - "indicator_color": "green", - "is_hidden": 0, - "label": "Banking", - "link_type": "DocType", - "links": [], - "modified": "2026-07-03 13:43:50.924019", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Banking", - "number_cards": [], - "owner": "Administrator", - "public": 1, - "quick_lists": [], - "roles": [], - "sequence_id": 49.0, - "shortcuts": [], - "sidebar_items": [ - { - "child": 0, - "collapsible": 1, - "icon": "book-open-check", - "indent": 0, - "keep_closed": 0, - "label": "Bank Clearance", - "link_to": "Bank Clearance", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "wrench", - "indent": 0, - "keep_closed": 0, - "label": "Bank Reconciliation", - "link_to": "Bank Reconciliation Tool", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "clipboard-check", - "indent": 0, - "keep_closed": 0, - "label": "Reconciliation Statement", - "link_to": "Bank Reconciliation Statement", - "link_type": "Report", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "split", - "indent": 0, - "keep_closed": 0, - "label": "Unreconcile Payment", - "link_to": "Unreconcile Payment", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "icon": "link", - "indent": 0, - "keep_closed": 0, - "label": "Process Payment Reconciliation", - "link_to": "Process Payment Reconciliation", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "database", - "indent": 1, - "keep_closed": 1, - "label": "Setup", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Section Break" - }, - { - "child": 1, - "collapsible": 1, - "icon": "", - "indent": 0, - "keep_closed": 0, - "label": "Bank", - "link_to": "Bank", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "icon": "", - "indent": 0, - "keep_closed": 0, - "label": "Bank Account", - "link_to": "Bank Account", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Bank Account Type", - "link_to": "Bank Account Type", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Bank Account Subtype", - "link_to": "Bank Account Subtype", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Bank Guarantee", - "link_to": "Bank Guarantee", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "icon": "", - "indent": 0, - "keep_closed": 0, - "label": "Plaid Settings", - "link_to": "Plaid Settings", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "scroll-text", - "indent": 1, - "keep_closed": 1, - "label": "Dunning", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Section Break" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Dunning", - "link_to": "Dunning", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Dunning Type", - "link_to": "Dunning Type", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - } - ], - "standard": 1, - "title": "Banking", - "type": "Workspace" -} diff --git a/erpnext/accounts/workspace/budgeting/budgeting.json b/erpnext/accounts/workspace/budgeting/budgeting.json deleted file mode 100644 index c5ea717fe52..00000000000 --- a/erpnext/accounts/workspace/budgeting/budgeting.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "app": "erpnext", - "charts": [], - "content": "[]", - "creation": "2026-06-14 14:38:20.315394", - "custom_blocks": [], - "docstatus": 0, - "doctype": "Workspace", - "for_user": "", - "hide_custom": 0, - "icon": "wallet", - "idx": 0, - "indicator_color": "green", - "is_hidden": 0, - "label": "Budgeting", - "link_type": "DocType", - "links": [], - "modified": "2026-07-03 04:24:48.116724", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Budgeting", - "number_cards": [], - "owner": "Administrator", - "public": 1, - "quick_lists": [], - "roles": [], - "sequence_id": 57.0, - "shortcuts": [], - "sidebar_items": [ - { - "child": 0, - "collapsible": 1, - "default_workspace": 0, - "icon": "briefcase-business", - "indent": 0, - "keep_closed": 0, - "label": "Budget", - "link_to": "Budget", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "default_workspace": 0, - "icon": "badge-cent", - "indent": 0, - "keep_closed": 0, - "label": "Cost Center", - "link_to": "Cost Center", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "default_workspace": 0, - "icon": "wallet", - "indent": 0, - "keep_closed": 0, - "label": "Accounting Dimension", - "link_to": "Accounting Dimension", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "default_workspace": 0, - "icon": "notepad-text", - "indent": 0, - "keep_closed": 0, - "label": "Cost Center Allocation", - "link_to": "Cost Center Allocation", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "default_workspace": 0, - "icon": "sheet", - "indent": 0, - "keep_closed": 0, - "label": "Budget Variance", - "link_to": "Budget Variance Report", - "link_type": "Report", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - } - ], - "standard": 1, - "title": "Budgeting", - "type": "Workspace" -} diff --git a/erpnext/accounts/workspace/payments/payments.json b/erpnext/accounts/workspace/payments/payments.json index fc29978e9e9..0553e0de207 100644 --- a/erpnext/accounts/workspace/payments/payments.json +++ b/erpnext/accounts/workspace/payments/payments.json @@ -15,7 +15,7 @@ "label": "Payments", "link_type": "DocType", "links": [], - "modified": "2026-07-03 13:43:50.184761", + "modified": "2026-07-14 12:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "module_onboarding": "Accounting Onboarding", @@ -25,9 +25,23 @@ "public": 1, "quick_lists": [], "roles": [], - "sequence_id": 47.0, + "sequence_id": 3.0, "shortcuts": [], "sidebar_items": [ + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "house", + "indent": 0, + "keep_closed": 0, + "label": "Home", + "link_to": "Payments", + "link_type": "Workspace", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, { "child": 0, "collapsible": 1, @@ -161,6 +175,180 @@ "show_arrow": 0, "type": "Link" }, + { + "child": 0, + "collapsible": 1, + "icon": "circle-dollar-sign", + "indent": 1, + "keep_closed": 0, + "label": "Banking", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "icon": "book-open-check", + "indent": 0, + "keep_closed": 0, + "label": "Bank Clearance", + "link_to": "Bank Clearance", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "icon": "wrench", + "indent": 0, + "keep_closed": 0, + "label": "Bank Reconciliation", + "link_to": "Bank Reconciliation Tool", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "icon": "clipboard-check", + "indent": 0, + "keep_closed": 0, + "label": "Reconciliation Statement", + "link_to": "Bank Reconciliation Statement", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "database", + "indent": 1, + "keep_closed": 1, + "label": "Banking Setup", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Bank", + "link_to": "Bank", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Bank Account", + "link_to": "Bank Account", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Bank Account Type", + "link_to": "Bank Account Type", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Bank Account Subtype", + "link_to": "Bank Account Subtype", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Bank Guarantee", + "link_to": "Bank Guarantee", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Plaid Settings", + "link_to": "Plaid Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "receipt-text", + "indent": 1, + "keep_closed": 1, + "label": "Dunning", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Dunning", + "link_to": "Dunning", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Dunning Type", + "link_to": "Dunning Type", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, { "child": 0, "collapsible": 1, diff --git a/erpnext/accounts/workspace/share_management/share_management.json b/erpnext/accounts/workspace/share_management/share_management.json deleted file mode 100644 index c48bec275ce..00000000000 --- a/erpnext/accounts/workspace/share_management/share_management.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "app": "erpnext", - "charts": [], - "content": "[]", - "creation": "2026-06-11 11:51:22.831729", - "custom_blocks": [], - "docstatus": 0, - "doctype": "Workspace", - "for_user": "", - "hide_custom": 0, - "icon": "coins", - "idx": 0, - "indicator_color": "green", - "is_hidden": 0, - "label": "Share Management", - "link_type": "DocType", - "links": [], - "modified": "2026-07-03 13:43:51.040978", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Share Management", - "number_cards": [], - "owner": "Administrator", - "public": 1, - "quick_lists": [], - "roles": [], - "sequence_id": 50.0, - "shortcuts": [], - "sidebar_items": [ - { - "child": 1, - "collapsible": 1, - "icon": "user", - "indent": 0, - "keep_closed": 0, - "label": "Shareholder", - "link_to": "Shareholder", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "icon": "move-horizontal", - "indent": 0, - "keep_closed": 0, - "label": "Share Transfer", - "link_to": "Share Transfer", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "icon": "list", - "indent": 0, - "keep_closed": 0, - "label": "Share Ledger", - "link_to": "Share Ledger", - "link_type": "Report", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "icon": "notepad-text", - "indent": 0, - "keep_closed": 0, - "label": "Share Balance", - "link_to": "Share Balance", - "link_type": "Report", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - } - ], - "standard": 1, - "title": "Share Management", - "type": "Workspace" -} diff --git a/erpnext/accounts/workspace/subscriptions/subscriptions.json b/erpnext/accounts/workspace/subscriptions/subscriptions.json deleted file mode 100644 index f97c4a09b95..00000000000 --- a/erpnext/accounts/workspace/subscriptions/subscriptions.json +++ /dev/null @@ -1,121 +0,0 @@ -{ - "app": "erpnext", - "charts": [], - "content": "[]", - "creation": "2026-06-14 14:08:36.817393", - "custom_blocks": [], - "docstatus": 0, - "doctype": "Workspace", - "for_user": "", - "hide_custom": 0, - "icon": "wallet", - "idx": 0, - "indicator_color": "green", - "is_hidden": 0, - "label": "Subscriptions", - "link_type": "DocType", - "links": [], - "modified": "2026-07-03 14:08:36.999272", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Subscriptions", - "number_cards": [], - "owner": "Administrator", - "public": 1, - "quick_lists": [], - "roles": [], - "sequence_id": 56.0, - "shortcuts": [], - "sidebar_items": [ - { - "child": 0, - "collapsible": 1, - "icon": "circle-dollar-sign", - "indent": 0, - "keep_closed": 0, - "label": "Subscription", - "link_to": "Subscription", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "receipt-text", - "indent": 0, - "keep_closed": 0, - "label": "Subscription Plan", - "link_to": "Subscription Plan", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "settings", - "indent": 0, - "keep_closed": 0, - "label": "Subscription Settings", - "link_to": "Subscription Settings", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "database", - "indent": 1, - "keep_closed": 1, - "label": "Setup", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Section Break" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Customer", - "link_to": "Customer", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Supplier", - "link_to": "Supplier", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Item", - "link_to": "Item", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - } - ], - "standard": 1, - "title": "Subscriptions", - "type": "Workspace" -} diff --git a/erpnext/accounts/workspace/taxes/taxes.json b/erpnext/accounts/workspace/taxes/taxes.json deleted file mode 100644 index e94bacb66d3..00000000000 --- a/erpnext/accounts/workspace/taxes/taxes.json +++ /dev/null @@ -1,188 +0,0 @@ -{ - "app": "erpnext", - "charts": [], - "content": "[]", - "creation": "2026-06-11 11:51:22.649582", - "custom_blocks": [], - "docstatus": 0, - "doctype": "Workspace", - "for_user": "", - "hide_custom": 0, - "icon": "coins", - "idx": 0, - "indicator_color": "green", - "is_hidden": 0, - "label": "Taxes", - "link_type": "DocType", - "links": [], - "modified": "2026-07-03 13:43:50.894825", - "modified_by": "Administrator", - "module": "Accounts", - "module_onboarding": "Accounting Onboarding", - "name": "Taxes", - "number_cards": [], - "owner": "Administrator", - "public": 1, - "quick_lists": [], - "roles": [], - "sequence_id": 48.0, - "shortcuts": [], - "sidebar_items": [ - { - "child": 0, - "collapsible": 1, - "icon": "panel-bottom-close", - "indent": 0, - "keep_closed": 0, - "label": "Sales Tax Template", - "link_to": "Sales Taxes and Charges Template", - "link_type": "DocType", - "navigate_to_tab": "", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "panel-top-close", - "indent": 0, - "keep_closed": 0, - "label": "Purchase Tax Template", - "link_to": "Purchase Taxes and Charges Template", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "package", - "indent": 0, - "keep_closed": 0, - "label": "Item Tax Template", - "link_to": "Item Tax Template", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "database", - "indent": 1, - "keep_closed": 1, - "label": "Setup", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Section Break" - }, - { - "child": 1, - "collapsible": 1, - "icon": "triangle", - "indent": 0, - "keep_closed": 0, - "label": "Tax Category", - "link_to": "Tax Category", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "icon": "book-open-text", - "indent": 0, - "keep_closed": 0, - "label": "Tax Rule", - "link_to": "Tax Rule", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "icon": "book-text", - "indent": 0, - "keep_closed": 0, - "label": "Tax Withholding Category", - "link_to": "Tax Withholding Category", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Tax Withholding Group", - "link_to": "Tax Withholding Group", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "notebook-text", - "indent": 0, - "keep_closed": 0, - "label": "Deduction Certificate", - "link_to": "Lower Deduction Certificate", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 0, - "collapsible": 1, - "icon": "sheet", - "indent": 1, - "keep_closed": 1, - "label": "Reports", - "link_to": "", - "link_type": "DocType", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Section Break" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "TDS Computation Summary", - "link_to": "TDS Computation Summary", - "link_type": "Report", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Tax Withholding Details", - "link_to": "Tax Withholding Details", - "link_type": "Report", - "open_in_new_tab": 0, - "show_arrow": 0, - "type": "Link" - } - ], - "standard": 1, - "title": "Taxes", - "type": "Workspace" -} diff --git a/erpnext/assets/dashboard_fixtures.py b/erpnext/assets/dashboard_fixtures.py deleted file mode 100644 index 0fd6c019f36..00000000000 --- a/erpnext/assets/dashboard_fixtures.py +++ /dev/null @@ -1,190 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -import json - -import frappe -from frappe import _ -from frappe.utils import get_date_str, nowdate - -from erpnext.accounts.dashboard_fixtures import _get_fiscal_year -from erpnext.buying.dashboard_fixtures import get_company_for_dashboards - - -def get_data(): - fiscal_year = _get_fiscal_year(nowdate()) - - if not fiscal_year: - return frappe._dict() - - year_start_date = get_date_str(fiscal_year.get("year_start_date")) - year_end_date = get_date_str(fiscal_year.get("year_end_date")) - - return frappe._dict( - { - "dashboards": get_dashboards(), - "charts": get_charts(fiscal_year, year_start_date, year_end_date), - "number_cards": get_number_cards(fiscal_year, year_start_date, year_end_date), - } - ) - - -def get_dashboards(): - return [ - { - "name": "Asset", - "dashboard_name": "Asset", - "charts": [ - {"chart": "Asset Value Analytics", "width": "Full"}, - {"chart": "Category-wise Asset Value", "width": "Half"}, - {"chart": "Location-wise Asset Value", "width": "Half"}, - ], - "cards": [ - {"card": "Total Assets"}, - {"card": "New Assets (This Year)"}, - {"card": "Asset Value"}, - ], - } - ] - - -def get_charts(fiscal_year, year_start_date, year_end_date): - company = get_company_for_dashboards() - return [ - { - "name": "Asset Value Analytics", - "chart_name": _("Asset Value Analytics"), - "chart_type": "Report", - "report_name": "Fixed Asset Register", - "is_custom": 1, - "group_by_type": "Count", - "number_of_groups": 0, - "is_public": 0, - "timespan": "Last Year", - "time_interval": "Yearly", - "timeseries": 0, - "filters_json": json.dumps( - { - "company": company, - "status": "In Location", - "filter_based_on": "Fiscal Year", - "from_fiscal_year": fiscal_year.get("name"), - "to_fiscal_year": fiscal_year.get("name"), - "period_start_date": year_start_date, - "period_end_date": year_end_date, - "date_based_on": "Purchase Date", - "group_by": "--Select a group--", - } - ), - "type": "Bar", - "custom_options": json.dumps( - { - "type": "bar", - "barOptions": {"stacked": 1}, - "axisOptions": {"shortenYAxisNumbers": 1}, - "tooltipOptions": {}, - } - ), - "doctype": "Dashboard Chart", - "y_axis": [], - }, - { - "name": "Category-wise Asset Value", - "chart_name": _("Category-wise Asset Value"), - "chart_type": "Report", - "report_name": "Fixed Asset Register", - "x_field": "asset_category", - "timeseries": 0, - "filters_json": json.dumps( - { - "company": company, - "status": "In Location", - "group_by": "Asset Category", - "asset_type": ["!=", "Existing Asset"], - } - ), - "type": "Donut", - "doctype": "Dashboard Chart", - "y_axis": [ - { - "parent": "Category-wise Asset Value", - "parentfield": "y_axis", - "parenttype": "Dashboard Chart", - "y_field": "asset_value", - "doctype": "Dashboard Chart Field", - } - ], - "custom_options": json.dumps( - {"type": "donut", "height": 300, "axisOptions": {"shortenYAxisNumbers": 1}} - ), - }, - { - "name": "Location-wise Asset Value", - "chart_name": "Location-wise Asset Value", - "chart_type": "Report", - "report_name": "Fixed Asset Register", - "x_field": "location", - "timeseries": 0, - "filters_json": json.dumps( - { - "company": company, - "status": "In Location", - "group_by": "Location", - "asset_type": ["!=", "Existing Asset"], - } - ), - "type": "Donut", - "doctype": "Dashboard Chart", - "y_axis": [ - { - "parent": "Location-wise Asset Value", - "parentfield": "y_axis", - "parenttype": "Dashboard Chart", - "y_field": "asset_value", - "doctype": "Dashboard Chart Field", - } - ], - "custom_options": json.dumps( - {"type": "donut", "height": 300, "axisOptions": {"shortenYAxisNumbers": 1}} - ), - }, - ] - - -def get_number_cards(fiscal_year, year_start_date, year_end_date): - return [ - { - "name": "Total Assets", - "label": _("Total Assets"), - "function": "Count", - "document_type": "Asset", - "is_public": 1, - "show_percentage_stats": 1, - "stats_time_interval": "Monthly", - "filters_json": "[]", - "doctype": "Number Card", - }, - { - "name": "New Assets (This Year)", - "label": _("New Assets (This Year)"), - "function": "Count", - "document_type": "Asset", - "is_public": 1, - "show_percentage_stats": 1, - "stats_time_interval": "Monthly", - "filters_json": json.dumps([["Asset", "creation", "between", [year_start_date, year_end_date]]]), - "doctype": "Number Card", - }, - { - "name": "Asset Value", - "label": _("Asset Value"), - "function": "Sum", - "aggregate_function_based_on": "value_after_depreciation", - "document_type": "Asset", - "is_public": 1, - "show_percentage_stats": 1, - "stats_time_interval": "Monthly", - "filters_json": "[]", - "doctype": "Number Card", - }, - ] diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 8e8f133b109..df8c48ff143 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -147,7 +147,15 @@ frappe.ui.form.on("Asset", { __("Actions") ); } - + if (frm.doc.status === "Fully Depreciated") { + frm.add_custom_button( + __("Asset Repair"), + function () { + frm.trigger("create_asset_repair"); + }, + __("Actions") + ); + } frm.add_custom_button( __("Split Asset"), function () { diff --git a/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json index d5d0327916c..7022d240a7a 100644 --- a/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json +++ b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json @@ -21,6 +21,8 @@ "serial_and_batch_bundle", "use_serial_batch_fields", "column_break_13", + "serial_batch_entries_section", + "serial_batch_entries_html", "section_break_bfqc", "serial_no", "column_break_mbuv", @@ -165,6 +167,15 @@ "fieldtype": "Check", "label": "Use Serial No / Batch Fields" }, + { + "fieldname": "serial_batch_entries_section", + "fieldtype": "Section Break", + "label": "Serial / Batch Entries" + }, + { + "fieldname": "serial_batch_entries_html", + "fieldtype": "HTML" + }, { "depends_on": "eval:doc.use_serial_batch_fields === 1", "fieldname": "section_break_bfqc", @@ -185,7 +196,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-03-05 12:46:01.074742", + "modified": "2026-07-18 10:00:00.000000", "modified_by": "Administrator", "module": "Assets", "name": "Asset Capitalization Stock Item", @@ -196,4 +207,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js index 4d9ef28ceae..2920ff7e381 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.js +++ b/erpnext/assets/doctype/asset_repair/asset_repair.js @@ -84,6 +84,15 @@ frappe.ui.form.on("Asset Repair", { }; }; } + if (frm.doc.asset) { + frappe.db.get_value("Asset", frm.doc.asset, "status").then(({ message }) => { + frm.set_df_property( + "capitalize_repair_cost", + "read_only", + message && message.status === "Fully Depreciated" + ); + }); + } }, show_general_ledger: function (frm) { diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.json b/erpnext/assets/doctype/asset_repair/asset_repair.json index 4fc9a31b875..a1081ecb188 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.json +++ b/erpnext/assets/doctype/asset_repair/asset_repair.json @@ -130,7 +130,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Asset", - "link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\"]]]", + "link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Sold\",\"Scrapped\",\"Cancelled\"]]]", "options": "Asset", "reqd": 1 }, @@ -275,7 +275,7 @@ "link_fieldname": "asset_repair" } ], - "modified": "2026-02-06 14:57:54.257572", + "modified": "2026-06-20 15:43:54.943335", "modified_by": "Administrator", "module": "Assets", "name": "Asset Repair", diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index e8b2f165c1f..0b3e1dbe389 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -69,12 +69,15 @@ class AssetRepair(AccountsController): self.check_repair_status() def validate_asset(self): - if self.asset_doc.status in ("Sold", "Fully Depreciated", "Scrapped"): + if self.asset_doc.status in ("Sold", "Scrapped"): frappe.throw( _("Asset {0} is in {1} status and cannot be repaired.").format( get_link_to_form("Asset", self.asset), self.asset_doc.status ) ) + if self.asset_doc.get_status() == "Fully Depreciated": + self.capitalize_repair_cost = 0 + self.increase_in_asset_life = 0 def validate_dates(self): if self.completion_date and (getdate(self.failure_date) > getdate(self.completion_date)): diff --git a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json index 5ee245339eb..bb2304ab50d 100644 --- a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json +++ b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json @@ -13,7 +13,9 @@ "serial_no", "column_break_xzfr", "pick_serial_and_batch", - "serial_and_batch_bundle" + "serial_and_batch_bundle", + "serial_batch_entries_section", + "serial_batch_entries_html" ], "fields": [ { @@ -72,12 +74,21 @@ { "fieldname": "column_break_xzfr", "fieldtype": "Column Break" + }, + { + "fieldname": "serial_batch_entries_section", + "fieldtype": "Section Break", + "label": "Serial / Batch Entries" + }, + { + "fieldname": "serial_batch_entries_html", + "fieldtype": "HTML" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-06-27 14:52:56.311166", + "modified": "2026-07-18 10:00:00.000000", "modified_by": "Administrator", "module": "Assets", "name": "Asset Repair Consumed Item", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index be27000db2b..0a28177ba74 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -259,6 +259,7 @@ class PurchaseOrder(BuyingController): "ref_dn_field": "material_request_item", "compare_fields": mri_compare_fields, "is_child_table": True, + "allow_duplicate_prev_row_id": True, }, } ) diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index b0c75c49d9e..b405c0b0be5 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -913,8 +913,10 @@ "fieldname": "job_card", "fieldtype": "Link", "label": "Job Card", + "no_copy": 1, "options": "Job Card", - "search_index": 1 + "print_hide": 1, + "read_only": 1 }, { "fieldname": "distributed_discount_amount", @@ -941,7 +943,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-06-08 21:00:00.000000", + "modified": "2026-07-15 10:30:04.600510", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/buying/doctype/supplier/supplier.js b/erpnext/buying/doctype/supplier/supplier.js index 4d2d64cfcc1..c95bdf864e7 100644 --- a/erpnext/buying/doctype/supplier/supplier.js +++ b/erpnext/buying/doctype/supplier/supplier.js @@ -2,7 +2,16 @@ // License: GNU General Public License v3. See license.txt frappe.ui.form.on("Supplier", { + restrict_to_companies(frm) { + if (!frm.doc.restrict_to_companies) { + frm.set_value("allowed_companies", []); + } + }, + setup: function (frm) { + frm.set_query("allowed_companies", () => ({ + query: "erpnext.stock.doctype.company_restriction.company_restriction.company_query", + })); frm.set_query("default_price_list", { buying: 1 }); if (frm.doc.__islocal == 1) { frm.set_value("represents_company", ""); diff --git a/erpnext/buying/doctype/supplier/supplier.json b/erpnext/buying/doctype/supplier/supplier.json index 12a40cbca7b..d9d8c11edb2 100644 --- a/erpnext/buying/doctype/supplier/supplier.json +++ b/erpnext/buying/doctype/supplier/supplier.json @@ -54,6 +54,9 @@ "tax_withholding_category", "tax_withholding_group", "settings_tab", + "company_restrictions_section", + "restrict_to_companies", + "allowed_companies", "invoice_settings_section", "is_transporter", "allow_purchase_invoice_creation_without_purchase_order", @@ -425,6 +428,26 @@ "fieldtype": "Tab Break", "label": "Settings" }, + { + "fieldname": "company_restrictions_section", + "fieldtype": "Section Break", + "label": "Company Restrictions" + }, + { + "default": "0", + "fieldname": "restrict_to_companies", + "fieldtype": "Check", + "label": "Restrict to Companies", + "description": "If checked, this Supplier is only available for transactions in the companies listed below." + }, + { + "fieldname": "allowed_companies", + "fieldtype": "Table MultiSelect", + "label": "Allowed Companies", + "options": "Company Restriction", + "depends_on": "eval:doc.restrict_to_companies", + "mandatory_depends_on": "eval:doc.restrict_to_companies" + }, { "fieldname": "contact_and_address_tab", "fieldtype": "Tab Break", @@ -562,7 +585,7 @@ "link_fieldname": "party" } ], - "modified": "2026-06-27 16:12:33.190257", + "modified": "2026-07-14 23:00:00.000000", "modified_by": "Administrator", "module": "Buying", "name": "Supplier", diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index 1de54ed9313..4e138721f77 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -16,7 +16,10 @@ from erpnext.accounts.party import ( validate_party_accounts, validate_party_currency_before_merging, ) -from erpnext.controllers.website_list_for_contact import add_role_for_portal_user +from erpnext.controllers.website_list_for_contact import ( + add_role_for_portal_user, + link_portal_users_to_contacts, +) from erpnext.utilities.transaction_base import TransactionBase @@ -36,12 +39,14 @@ class Supplier(TransactionBase): from erpnext.buying.doctype.customer_number_at_supplier.customer_number_at_supplier import ( CustomerNumberAtSupplier, ) + from erpnext.stock.doctype.company_restriction.company_restriction import CompanyRestriction from erpnext.utilities.doctype.portal_user.portal_user import PortalUser accounts: DF.Table[PartyAccount] alias: DF.Data | None allow_purchase_invoice_creation_without_purchase_order: DF.Check allow_purchase_invoice_creation_without_purchase_receipt: DF.Check + allowed_companies: DF.TableMultiSelect[CompanyRestriction] companies: DF.Table[AllowedToTransactWith] country: DF.Link | None customer_numbers: DF.Table[CustomerNumberAtSupplier] @@ -67,6 +72,7 @@ class Supplier(TransactionBase): primary_address: DF.TextEditor | None release_date: DF.Date | None represents_company: DF.Link | None + restrict_to_companies: DF.Check supplier_details: DF.Text | None supplier_group: DF.Link | None supplier_name: DF.Data @@ -109,6 +115,7 @@ class Supplier(TransactionBase): def on_update(self): self.create_primary_contact() self.create_primary_address() + link_portal_users_to_contacts(self) def add_role_for_user(self): for portal_user in self.portal_users: diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py index 8f41296f57e..1b27d5aed22 100644 --- a/erpnext/buying/doctype/supplier/test_supplier.py +++ b/erpnext/buying/doctype/supplier/test_supplier.py @@ -202,3 +202,24 @@ class TestSupplierPortal(ERPNextTestSuite): _, suppliers = get_customers_suppliers("Purchase Order", user) self.assertIn(supplier.name, suppliers) + + def test_portal_user_contact_link(self): + user_email = frappe.generate_hash() + "@example.com" + user = frappe.new_doc("User") + user.email = user_email + user.first_name = "Test Portal Contact User" + user.send_welcome_email = False + user.insert(ignore_permissions=True) + + contact = frappe.new_doc("Contact") + contact.first_name = "Test Portal Contact User" + contact.add_email(user_email, is_primary=1) + contact.links = [] + contact.insert(ignore_permissions=True) + + supplier = create_supplier() + supplier.append("portal_users", {"user": user.name}) + supplier.save() + + contact.reload() + self.assertTrue(contact.has_link("Supplier", supplier.name)) diff --git a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json index c131439463f..31efaa6690b 100644 --- a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json +++ b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json @@ -307,6 +307,7 @@ "fieldname": "net_rate", "fieldtype": "Currency", "label": "Net Rate", + "options": "currency", "print_hide": 1, "read_only": 1 }, @@ -613,7 +614,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-06-17 12:05:52.441645", + "modified": "2026-07-15 10:33:24.855979", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation Item", diff --git a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py index 82abbb3ae09..1b99160a3c8 100644 --- a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py +++ b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py @@ -201,14 +201,16 @@ def make_all_scorecards(docname: str): while (start_date < todays) and (end_date <= todays): # check to make sure there is no scorecard period already created + # (inclusive bounds: a single-day period — supplier created on a month's + # last day — must match its own window, else it is re-created every run) scorecards = frappe.get_all( "Supplier Scorecard Period", fields=["name"], filters={ "scorecard": docname, "docstatus": 1, - "start_date": ["<", end_date], - "end_date": [">", start_date], + "start_date": ["<=", end_date], + "end_date": [">=", start_date], }, order_by="end_date desc", ) diff --git a/erpnext/buying/doctype_settings_map/purchase_order_(standard)/purchase_order_(standard).json b/erpnext/buying/doctype_settings_map/purchase_order_(standard)/purchase_order_(standard).json new file mode 100644 index 00000000000..8f8318021c0 --- /dev/null +++ b/erpnext/buying/doctype_settings_map/purchase_order_(standard)/purchase_order_(standard).json @@ -0,0 +1,52 @@ +{ + "applies_to_doctype": "Purchase Order", + "creation": "2026-07-03 14:19:38.781743", + "docstatus": 0, + "doctype": "DocType Settings Map", + "idx": 0, + "is_active": 1, + "is_standard": 1, + "mappings": [ + { + "setting_field": "allow_negative_rates_for_items", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "allow_zero_qty_in_purchase_order", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "maintain_same_rate", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "maintain_same_rate_action", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "role_to_override_stop_action", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "over_order_allowance", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "over_delivery_receipt_allowance", + "settings_doctype": "Stock Settings" + }, + { + "setting_field": "role_allowed_to_over_deliver_receive", + "settings_doctype": "Stock Settings" + }, + { + "setting_field": "unlink_advance_payment_on_cancelation_of_order", + "settings_doctype": "Accounts Settings" + } + ], + "modified": "2026-07-20 15:54:26.047600", + "modified_by": "Administrator", + "module": "Buying", + "name": "Purchase Order (Standard)", + "owner": "Administrator" +} diff --git a/erpnext/buying/doctype_settings_map/request_for_quotation_(standard)/request_for_quotation_(standard).json b/erpnext/buying/doctype_settings_map/request_for_quotation_(standard)/request_for_quotation_(standard).json new file mode 100644 index 00000000000..fe64ff981df --- /dev/null +++ b/erpnext/buying/doctype_settings_map/request_for_quotation_(standard)/request_for_quotation_(standard).json @@ -0,0 +1,24 @@ +{ + "applies_to_doctype": "Request for Quotation", + "creation": "2026-07-03 17:14:54.156469", + "docstatus": 0, + "doctype": "DocType Settings Map", + "idx": 0, + "is_active": 1, + "is_standard": 1, + "mappings": [ + { + "setting_field": "allow_zero_qty_in_request_for_quotation", + "settings_doctype": "Buying Settings" + }, + { + "setting_field": "fixed_email", + "settings_doctype": "Buying Settings" + } + ], + "modified": "2026-07-03 17:18:03.006829", + "modified_by": "Administrator", + "module": "Buying", + "name": "Request for Quotation (Standard)", + "owner": "Administrator" +} diff --git a/erpnext/buying/doctype_settings_map/supplier_quotation_(standard)/supplier_quotation_(standard).json b/erpnext/buying/doctype_settings_map/supplier_quotation_(standard)/supplier_quotation_(standard).json new file mode 100644 index 00000000000..950a8e96c10 --- /dev/null +++ b/erpnext/buying/doctype_settings_map/supplier_quotation_(standard)/supplier_quotation_(standard).json @@ -0,0 +1,20 @@ +{ + "applies_to_doctype": "Supplier Quotation", + "creation": "2026-07-03 17:14:32.891939", + "docstatus": 0, + "doctype": "DocType Settings Map", + "idx": 0, + "is_active": 1, + "is_standard": 1, + "mappings": [ + { + "setting_field": "allow_zero_qty_in_supplier_quotation", + "settings_doctype": "Buying Settings" + } + ], + "modified": "2026-07-03 17:14:32.891939", + "modified_by": "Administrator", + "module": "Buying", + "name": "Supplier Quotation (Standard)", + "owner": "Administrator" +} diff --git a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py index dd518e838ad..f220b9a5308 100644 --- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py +++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py @@ -14,7 +14,6 @@ def execute(filters=None): conditions = get_columns(filters, "Purchase Order") data = get_data(filters, conditions) chart_data = get_chart_data(data, conditions, filters) - return conditions["columns"], data, None, chart_data @@ -39,9 +38,15 @@ def get_chart_data(data, conditions, filters): labels = [column.split(":")[0].replace(" (Amt)", "") for column in columns] datapoints = [0] * len(labels) + group_by_col_idx = None + if filters.get("group_by"): + group_by_col_idx = conditions["columns"].index(conditions["grbc"][0]) + for row in data: - # If group by filter, don't add first row of group (it's already summed) - if not row[start]: + # Skip the final grand-total row + if row[0] == f"'{_('Total')}'": + continue + if group_by_col_idx is not None and row[group_by_col_idx] == "": continue # Remove None values and compute only periodic data row = [x if x else 0 for x in row[start:-2]] @@ -60,4 +65,6 @@ def get_chart_data(data, conditions, filters): "type": "line", "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } diff --git a/erpnext/buying/report/purchase_order_trends/test_purchase_order_trends.py b/erpnext/buying/report/purchase_order_trends/test_purchase_order_trends.py index 90d84447cb7..d11ad290120 100644 --- a/erpnext/buying/report/purchase_order_trends/test_purchase_order_trends.py +++ b/erpnext/buying/report/purchase_order_trends/test_purchase_order_trends.py @@ -2,7 +2,10 @@ # License: GNU General Public License v3. See license.txt import frappe +from frappe import _ +from frappe.utils import today +from erpnext.accounts.utils import get_fiscal_year from erpnext.tests.utils import ERPNextTestSuite @@ -30,3 +33,166 @@ class TestPurchaseOrderTrends(ERPNextTestSuite): self.assertTrue(columns) supplier_rows = [row for row in data if row[0] == "_Test Supplier"] self.assertEqual(len(supplier_rows), 1) + + def test_total_row_not_double_counted_in_chart(self): + # Regression test for the fix in trends.calculate_total_row that populates the + # Total row's Currency column. Before the fix in get_chart_data (skipping the + # Total row by label instead of `if not row[start]`), that populated Currency + # cell made the Total-row-skip guard falsy, so the already-summed Total row got + # added into the chart a second time (a PO of qty=3, rate=100 -> 300 read as 600). + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order(supplier="_Test Supplier", qty=3, rate=100, transaction_date=today()) + + fiscal_year = get_fiscal_year(today())[0] + + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + } + ) + + columns, data, _message, chart = execute(filters) + + self.assertTrue(columns) + self.assertTrue(data) + + # The Total row (present in `data`) must not be re-summed into the chart's datapoints. + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] # Total(Amt) is the last column + + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + self.assertEqual(chart_total, expected_total) + self.assertEqual(chart_total, 300) + + def test_chart_currency_matches_company_currency(self): + # Regression test: the chart's "currency" key should reflect the transacting + # company's currency (conditions["company_currency"]), not a stale global default. + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order(supplier="_Test Supplier", qty=1, rate=100, transaction_date=today()) + + fiscal_year = get_fiscal_year(today())[0] + + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + } + ) + + _columns, _data, _message, chart = execute(filters) + + expected_currency = frappe.get_cached_value("Company", "_Test Company", "default_currency") + self.assertEqual(chart["currency"], expected_currency) + + def test_group_by_chart_matches_table_total_with_mixed_group_sizes(self): + # _Test Item is split across two suppliers -> two detail rows under one header row. + # _Test Item 2 has only one supplier -> exactly one detail row under its header row. + # A regression that double-counts header rows would inflate the chart above 600; + # a regression that zeroes single-group rows would report less than 600. + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order( + item_code="_Test Item", supplier="_Test Supplier", qty=3, rate=100, transaction_date=today() + ) + create_purchase_order( + item_code="_Test Item", supplier="_Test Supplier 1", qty=2, rate=100, transaction_date=today() + ) + create_purchase_order( + item_code="_Test Item 2", supplier="_Test Supplier", qty=1, rate=100, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + "group_by": "Supplier", + } + ) + + columns, data, _message, chart = execute(filters) + self.assertTrue(columns) + self.assertTrue(data) + + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 300 (item/supplier) + 200 (item/supplier1) + 100 (item2/supplier) = 600 + self.assertEqual(expected_total, 600) + self.assertEqual(chart_total, expected_total) + + def test_group_by_swapped_roles_based_on_supplier_group_by_item(self): + # Same regression, opposite role assignment: based_on="Supplier" with group_by="Item". + # Supplier's based_on_cols (Supplier, Supplier Name, Supplier Group, Currency) put the + # group_by placeholder at a different column index than the Item-based_on case above, + # exercising the alternate `inc`/`ind` arithmetic. + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order( + item_code="_Test Item", supplier="_Test Supplier", qty=3, rate=100, transaction_date=today() + ) + create_purchase_order( + item_code="_Test Item 2", supplier="_Test Supplier", qty=1, rate=100, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Supplier", + "group_by": "Item", + } + ) + + columns, data, _message, chart = execute(filters) + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 300 + 100 = 400 + self.assertEqual(expected_total, 400) + self.assertEqual(chart_total, expected_total) + + def test_group_by_single_group_value_not_zeroed(self): + # Isolates the specific failure mode flagged in review: a based_on value with exactly + # one associated group value must still contribute its real amount to the chart, not 0. + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order( + item_code="_Test Item", supplier="_Test Supplier", qty=2, rate=150, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + "group_by": "Supplier", + } + ) + + columns, data, _message, chart = execute(filters) + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + self.assertGreater(chart_total, 0) + self.assertEqual(chart_total, 300) diff --git a/erpnext/buying/workspace/buying/buying.json b/erpnext/buying/workspace/buying/buying.json index cfd480b3312..4fdfd1fe342 100644 --- a/erpnext/buying/workspace/buying/buying.json +++ b/erpnext/buying/workspace/buying/buying.json @@ -501,7 +501,7 @@ "type": "Link" } ], - "modified": "2026-07-03 13:43:50.509039", + "modified": "2026-07-14 12:00:00.000000", "modified_by": "Administrator", "module": "Buying", "module_onboarding": "Buying Onboarding", @@ -754,6 +754,83 @@ "show_arrow": 0, "type": "Link" }, + { + "child": 0, + "collapsible": 1, + "icon": "rocket", + "indent": 1, + "keep_closed": 1, + "label": "Subcontracting", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "icon": "folder-tree", + "indent": 0, + "keep_closed": 0, + "label": "Subcontracting BOM", + "link_to": "Subcontracting BOM", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Subcontracting Inward Order", + "link_to": "Subcontracting Inward Order", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Subcontracting Delivery", + "link_to": "Stock Entry", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Subcontracting Order", + "link_to": "Subcontracting Order", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Subcontracting Receipt", + "link_to": "Subcontracting Receipt", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, { "child": 0, "collapsible": 1, @@ -910,6 +987,45 @@ "show_arrow": 0, "type": "Link" }, + { + "child": 1, + "collapsible": 1, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Subcontract Order Summary", + "link_to": "Subcontract Order Summary", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Materials To Be Transferred", + "link_to": "Subcontracted Raw Materials To Be Transferred", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Items To Be Received", + "link_to": "Subcontracted Item To Be Received", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, { "child": 0, "collapsible": 1, diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 6b477471db7..56e6e381bb5 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -234,7 +234,9 @@ class AccountsController(TransactionBase): if self.is_return: self.validate_qty() else: - self.validate_deferred_start_and_end_date() + from erpnext.accounts.services.deferred_accounting import DeferredAccountingService + + DeferredAccountingService(self).validate_start_and_end_date() from erpnext.accounts.services.internal_transfer import InternalTransferService @@ -262,7 +264,9 @@ class AccountsController(TransactionBase): validate_return(self) - self.validate_all_documents_schedule() + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(self).validate_all_documents_schedule() from erpnext.accounts.services.party_validation import PartyValidator @@ -286,7 +290,9 @@ class AccountsController(TransactionBase): self.set_advance_gain_or_loss() - self.validate_deferred_income_expense_account() + from erpnext.accounts.services.deferred_accounting import DeferredAccountingService + + DeferredAccountingService(self).validate_income_expense_account() InternalTransferService(self).set_account() if self.doctype == "Purchase Invoice": @@ -504,89 +510,10 @@ class AccountsController(TransactionBase): ) ) - def validate_deferred_income_expense_account(self): - field_map = { - "Sales Invoice": "deferred_revenue_account", - "Purchase Invoice": "deferred_expense_account", - } - - for item in self.get("items"): - if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"): - if not item.get(field_map.get(self.doctype)): - default_deferred_account = frappe.get_cached_value( - "Company", self.company, "default_" + field_map.get(self.doctype) - ) - if not default_deferred_account: - frappe.throw( - _( - "Row #{0}: Please update deferred revenue/expense account in item row or default account in company master" - ).format(item.idx) - ) - else: - item.set(field_map.get(self.doctype), default_deferred_account) - def validate_auto_repeat_subscription_dates(self): if self.get("from_date") and self.get("to_date") and getdate(self.from_date) > getdate(self.to_date): frappe.throw(_("To Date cannot be before From Date"), title=_("Invalid Auto Repeat Date")) - def validate_deferred_start_and_end_date(self): - for d in self.items: - if d.get("enable_deferred_revenue") or d.get("enable_deferred_expense"): - if not (d.service_start_date and d.service_end_date): - frappe.throw( - _("Row #{0}: Service Start and End Date is required for deferred accounting").format( - d.idx - ) - ) - elif getdate(d.service_start_date) > getdate(d.service_end_date): - frappe.throw( - _("Row #{0}: Service Start Date cannot be greater than Service End Date").format( - d.idx - ) - ) - elif getdate(self.posting_date) > getdate(d.service_end_date): - frappe.throw( - _("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(d.idx) - ) - - def validate_invoice_documents_schedule(self): - if ( - self.is_return - or (self.doctype == "Purchase Invoice" and self.is_paid) - or (self.doctype == "Sales Invoice" and self.is_pos) - or self.get("is_opening") == "Yes" - ): - self.payment_terms_template = "" - self.payment_schedule = [] - - if self.is_return: - return - - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - ps = PaymentScheduleService(self) - ps.validate_payment_schedule_dates() - ps.set_due_date() - ps.set_payment_schedule() - if not self.get("ignore_default_payment_terms_template"): - ps.validate_payment_schedule_amount() - self.validate_due_date() - self.validate_advance_entries() - - def validate_non_invoice_documents_schedule(self): - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - ps = PaymentScheduleService(self) - ps.set_payment_schedule() - ps.validate_payment_schedule_dates() - ps.validate_payment_schedule_amount() - - def validate_all_documents_schedule(self): - if self.doctype in ("Sales Invoice", "Purchase Invoice"): - self.validate_invoice_documents_schedule() - elif self.doctype in ("Quotation", "Purchase Order", "Sales Order"): - self.validate_non_invoice_documents_schedule() - def before_print(self, settings=None): if self.doctype in [ "Purchase Order", diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 1f947bf1fb6..842114f6512 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -330,30 +330,38 @@ class BuyingController(SubcontractingController): address_display_field, render_address(self.get(address_field), check_permissions=False) ) + def get_validated_purchase_expense_details(self, item_code): + fields = ("purchase_expense_account", "purchase_expense_contra_account") + details = get_purchase_expense_account(item_code, self.company) + + for field in fields: + if not details.get(field): + details[field] = frappe.get_cached_value("Company", self.company, field) + + if not any(details.get(field) for field in fields): + return None + + for field in fields: + if not details.get(field): + frappe.throw( + _("Please set {0} in Company {1} or in the Item Defaults of Item {2}").format( + frappe.bold(_(frappe.unscrub(field))), self.company, item_code + ) + ) + + return details + def set_gl_entry_for_purchase_expense(self, gl_entries): + if not cint(frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")): + return + if self.doctype == "Purchase Invoice" and not self.update_stock: return for row in self.items: - details = get_purchase_expense_account(row.item_code, self.company) - - if not details.purchase_expense_account: - details.purchase_expense_account = frappe.get_cached_value( - "Company", self.company, "purchase_expense_account" - ) - - if not details.purchase_expense_account: - return - - if not details.purchase_expense_contra_account: - details.purchase_expense_contra_account = frappe.get_cached_value( - "Company", self.company, "purchase_expense_contra_account" - ) - - if not details.purchase_expense_contra_account: - frappe.throw( - _("Please set Purchase Expense Contra Account in Company {0}").format(self.company) - ) + details = self.get_validated_purchase_expense_details(row.item_code) + if not details: + continue amount = flt(row.valuation_rate * row.stock_qty, row.precision("base_amount")) self.add_gl_entry( diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index 4dadc91da3b..3e4f632307e 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -186,6 +186,68 @@ def update_variant_attribute_values(item_attribute): frappe.flags.attribute_values = None +def get_attribute_abbr_renames(item_attribute): + """Return the set of (current) attribute values whose abbreviation was renamed.""" + if item_attribute.numeric_values: + return set() + + db_value = item_attribute.get_doc_before_save() + if not db_value: + return set() + + old_abbrs = {d.name: d.abbr for d in db_value.item_attribute_values} + changed_values = set() + + for row in item_attribute.item_attribute_values: + if row.name in old_abbrs and old_abbrs[row.name] != row.abbr: + changed_values.add(row.attribute_value) + + return changed_values + + +def update_variant_item_codes_for_abbr_renames(item_attribute): + """Rebuild item_code/item_name of variant Items affected by a renamed Item Attribute abbreviation.""" + changed_values = get_attribute_abbr_renames(item_attribute) + if not changed_values: + return + + item_variant_table = frappe.qb.DocType("Item Variant Attribute") + variant_names = ( + frappe.qb.from_(item_variant_table) + .select(item_variant_table.parent) + .where(item_variant_table.attribute == item_attribute.name) + .where(item_variant_table.attribute_value.isin(list(changed_values))) + .distinct() + .run(pluck=True) + ) + + for variant_name in variant_names: + rename_variant_item_code(variant_name) + + +def rename_variant_item_code(variant_name): + """Recompute a variant's item_code/item_name from its template and current attribute abbreviations, + renaming the Item if it has changed.""" + variant = frappe.get_doc("Item", variant_name) + if not variant.variant_of: + return + + template = frappe.get_cached_doc("Item", variant.variant_of) + + new_code = frappe._dict({"item_code": None, "item_name": None, "attributes": variant.attributes}) + make_variant_item_code(template.item_code, template.item_name, new_code) + + if not new_code.item_code or new_code.item_code == variant.item_code: + return + + frappe.rename_doc("Item", variant.item_code, new_code.item_code) + + # Keep item_name in lockstep with item_code: both are derived from the same abbreviation, so + # item_name is always rebuilt here too, even if it had since been customized away from that pattern. + if new_code.item_name and new_code.item_name != variant.item_name: + frappe.db.set_value("Item", new_code.item_code, "item_name", new_code.item_name) + + def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True): allow_rename_attribute_value = frappe.db.get_single_value( "Item Variant Settings", "allow_rename_attribute_value" diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index dd587ef2f22..492726141ee 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -20,11 +20,12 @@ from frappe.query_builder.functions import ( Substring, Sum, ) -from frappe.utils import cint, nowdate, today, unique +from frappe.utils import nowdate, today, unique from pypika import Order import erpnext from erpnext.accounts.utils import build_qb_match_conditions +from erpnext.stock.doctype.company_restriction.company_restriction import get_restriction_criterion from erpnext.stock.get_item_details import _get_item_tax_template from erpnext.stock.utils import get_combine_datetime from erpnext.utilities.query import get_filter_conditions_qb @@ -214,6 +215,7 @@ def item_query( doctype = "Item" filters = frappe.parse_json(filters) + company = filters.pop("company", None) if isinstance(filters, dict) else None if filters and isinstance(filters, dict): if filters.get("customer") or filters.get("supplier"): @@ -361,6 +363,9 @@ def item_query( .offset(start) ) + if company: + query = query.where(get_restriction_criterion("Item", [company])) + return query.run(as_dict=as_dict) @@ -411,7 +416,7 @@ def get_project_name( if filters.get("company"): qb_filter_and_conditions.append(proj.company == filters.get("company")) - qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled"])) + qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled", "On hold"])) q = qb.from_(proj) @@ -809,10 +814,8 @@ def get_filtered_dimensions( for field in searchfields: df = meta.get_field(field) - if df and df.fieldtype != "Check": + if not df or df.fieldtype != "Check": or_filters.append([field, "LIKE", "%%%s%%" % txt]) - else: - or_filters.append([field, "=", cint(txt)]) fields.append(field) if dimension_filters: diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 733a7160da8..64d2a0bd62a 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -565,6 +565,7 @@ def show_accounting_ledger_preview(company: str, doctype: str, docname: str): filters = frappe._dict(company=company, include_dimensions=1) doc = frappe.get_lazy_doc(doctype, docname) + doc.check_permission("read") doc.run_method("before_gl_preview") gl_columns, gl_data = get_accounting_ledger_preview(doc, filters) @@ -580,6 +581,7 @@ def show_stock_ledger_preview(company: str, doctype: str, docname: str): filters = frappe._dict(company=company) doc = frappe.get_lazy_doc(doctype, docname) + doc.check_permission("read") doc.run_method("before_sl_preview") sl_columns, sl_data = get_stock_ledger_preview(doc, filters) diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index 530b6574a42..2563d3b7fd7 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -6,6 +6,7 @@ import frappe from frappe import _ from frappe.utils import DateTimeLikeObject, getdate, today +import erpnext from erpnext.accounts.utils import get_fiscal_year @@ -42,6 +43,9 @@ def get_columns(filters, trans): "addl_tables": based_on_details["addl_tables"], "addl_tables_relational_cond": based_on_details.get("addl_tables_relational_cond", ""), } + conditions["company_currency"] = ( + erpnext.get_company_currency(filters.get("company")) if filters.get("company") else None + ) return conditions @@ -214,7 +218,7 @@ def get_data(filters, conditions): data.append(des) - total_row = calculate_total_row(data1, conditions["columns"]) + total_row = calculate_total_row(data1, conditions["columns"], conditions.get("company_currency")) data.append(total_row) else: data = frappe.db.sql( @@ -239,20 +243,23 @@ def get_data(filters, conditions): as_list=1, ) - total_row = calculate_total_row(data, conditions["columns"]) + total_row = calculate_total_row(data, conditions["columns"], conditions.get("company_currency")) data.append(total_row) return data -def calculate_total_row(data, columns): +def calculate_total_row(data, columns, company_currency=None): def wrap_in_quotes(label): return f"'{label}'" total_values = {} + currency_col_idx = None for i, col in enumerate(columns): if "Float" in col or "Currency/currency" in col: total_values[i] = 0 + if "Link/Currency" in col: + currency_col_idx = i for row in data: for i in total_values.keys(): @@ -262,6 +269,9 @@ def calculate_total_row(data, columns): for i in range(1, len(columns)): total_row.append(total_values.get(i, None)) + if currency_col_idx is not None: + total_row[currency_col_idx] = company_currency + return total_row @@ -371,7 +381,10 @@ def based_wise_columns_query(based_on, trans): # based_on_cols, based_on_select, based_on_group_by, addl_tables if based_on == "Item": - based_on_details["based_on_cols"] = ["Item:Link/Item:120", "Item Name:Data:120"] + based_on_details["based_on_cols"] = [ + {"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"}, + {"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"}, + ] # item_name is an editable per-line field, not functionally dependent on item_code, so it # is aggregated (one row per item_code) rather than added to GROUP BY (which would split # the row and change the MariaDB row count). See get_data's group-by query. @@ -380,7 +393,15 @@ def based_wise_columns_query(based_on, trans): based_on_details["addl_tables"] = "" elif based_on == "Item Group": - based_on_details["based_on_cols"] = ["Item Group:Link/Item Group:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Item Group"), + "fieldtype": "Link", + "options": "Item Group", + "width": 120, + "fieldname": "item_group", + } + ] based_on_details["based_on_select"] = "t2.item_group," based_on_details["based_on_group_by"] = "t2.item_group" based_on_details["addl_tables"] = "" @@ -388,18 +409,47 @@ def based_wise_columns_query(based_on, trans): elif based_on == "Customer": if trans == "Quotation": based_on_details["based_on_cols"] = [ - "Party:Link/Customer:120", - "Party Name:Data:120", - "Territory:Link/Territory:120", + { + "label": _("Party"), + "fieldtype": "Link", + "options": "Customer", + "width": 120, + "fieldname": "party", + }, + {"label": _("Party Name"), "fieldtype": "Data", "width": 120, "fieldname": "party_name"}, + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + }, ] based_on_details[ "based_on_select" ] = "t1.party_name, Max(t1.customer_name) as customer_name, Max(t1.territory) as territory," else: based_on_details["based_on_cols"] = [ - "Customer:Link/Customer:120", - "Customer Name:Data:120", - "Territory:Link/Territory:120", + { + "label": _("Customer"), + "fieldtype": "Link", + "options": "Customer", + "width": 120, + "fieldname": "customer", + }, + { + "label": _("Customer Name"), + "fieldtype": "Data", + "width": 120, + "fieldname": "customer_name", + }, + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + }, ] based_on_details[ "based_on_select" @@ -410,16 +460,35 @@ def based_wise_columns_query(based_on, trans): based_on_details["addl_tables"] = "" elif based_on == "Customer Group": - based_on_details["based_on_cols"] = ["Customer Group:Link/Customer Group"] + based_on_details["based_on_cols"] = [ + { + "label": _("Customer Group"), + "fieldtype": "Link", + "options": "Customer Group", + "fieldname": "customer_group", + } + ] based_on_details["based_on_select"] = "t1.customer_group," based_on_details["based_on_group_by"] = "t1.customer_group" based_on_details["addl_tables"] = "" elif based_on == "Supplier": based_on_details["based_on_cols"] = [ - "Supplier:Link/Supplier:120", - "Supplier Name:Data:120", - "Supplier Group:Link/Supplier Group:140", + { + "label": _("Supplier"), + "fieldtype": "Link", + "options": "Supplier", + "width": 120, + "fieldname": "supplier", + }, + {"label": _("Supplier Name"), "fieldtype": "Data", "width": 120, "fieldname": "supplier_name"}, + { + "label": _("Supplier Group"), + "fieldtype": "Link", + "options": "Supplier Group", + "width": 140, + "fieldname": "supplier_group", + }, ] # supplier_name is a stored per-transaction field (not functionally dependent on supplier), so # it is aggregated to keep one row per supplier — matching the prior MariaDB output, which grouped @@ -433,26 +502,58 @@ def based_wise_columns_query(based_on, trans): based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name" elif based_on == "Supplier Group": - based_on_details["based_on_cols"] = ["Supplier Group:Link/Supplier Group:140"] + based_on_details["based_on_cols"] = [ + { + "label": _("Supplier Group"), + "fieldtype": "Link", + "options": "Supplier Group", + "width": 140, + "fieldname": "supplier_group", + } + ] based_on_details["based_on_select"] = "t3.supplier_group," based_on_details["based_on_group_by"] = "t3.supplier_group" based_on_details["addl_tables"] = ",`tabSupplier` t3" based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name" elif based_on == "Territory": - based_on_details["based_on_cols"] = ["Territory:Link/Territory:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + } + ] based_on_details["based_on_select"] = "t1.territory," based_on_details["based_on_group_by"] = "t1.territory" based_on_details["addl_tables"] = "" elif based_on == "Project": if trans in ["Sales Invoice", "Delivery Note", "Sales Order"]: - based_on_details["based_on_cols"] = ["Project:Link/Project:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Project"), + "fieldtype": "Link", + "options": "Project", + "width": 120, + "fieldname": "project", + } + ] based_on_details["based_on_select"] = "t1.project," based_on_details["based_on_group_by"] = "t1.project" based_on_details["addl_tables"] = "" elif trans in ["Purchase Order", "Purchase Invoice", "Purchase Receipt"]: - based_on_details["based_on_cols"] = ["Project:Link/Project:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Project"), + "fieldtype": "Link", + "options": "Project", + "width": 120, + "fieldname": "project", + } + ] based_on_details["based_on_select"] = "t2.project," based_on_details["based_on_group_by"] = "t2.project" based_on_details["addl_tables"] = "" @@ -461,7 +562,15 @@ def based_wise_columns_query(based_on, trans): based_on_details["based_on_select"] += "t4.default_currency as currency," based_on_details["based_on_group_by"] += ", t4.default_currency" - based_on_details["based_on_cols"].append("Currency:Link/Currency:120") + based_on_details["based_on_cols"].append( + { + "label": _("Currency"), + "fieldtype": "Link", + "options": "Currency", + "width": 120, + "fieldname": "currency", + } + ) based_on_details["addl_tables"] += ", `tabCompany` t4" based_on_details["addl_tables_relational_cond"] = ( based_on_details.get("addl_tables_relational_cond", "") + " and t1.company = t4.name" @@ -472,6 +581,14 @@ def based_wise_columns_query(based_on, trans): def group_wise_column(group_by): if group_by: - return [group_by + ":Link/" + group_by + ":120"] + return [ + { + "label": _(group_by), + "fieldtype": "Link", + "options": group_by, + "width": 120, + "fieldname": frappe.scrub(group_by), + } + ] else: return [] diff --git a/erpnext/controllers/website_list_for_contact.py b/erpnext/controllers/website_list_for_contact.py index 33416a952ac..6d10475ac6e 100644 --- a/erpnext/controllers/website_list_for_contact.py +++ b/erpnext/controllers/website_list_for_contact.py @@ -7,6 +7,8 @@ import json import frappe from frappe import _ from frappe.modules.utils import get_module_app +from frappe.query_builder import Criterion +from frappe.query_builder.functions import Lower from frappe.utils import cint, flt, has_common from frappe.utils.user import is_website_user @@ -309,3 +311,63 @@ def add_role_for_portal_user(portal_user, role): user_doc.add_roles(role) frappe.msgprint(_("Added {1} role to user {0}.").format(frappe.bold(user_doc.name), role), alert=True) + + +def link_portal_users_to_contacts(doc): + """When portal users are added to Supplier/Customer, link them to the Contact profile.""" + # a User's name is its (lowercased) email, so portal_users are already the emails + portal_users = {p.user for p in doc.get("portal_users") or [] if p.user} + if not portal_users: + return + + before = doc.get_doc_before_save() + if before: + previous_users = {p.user for p in before.get("portal_users") or [] if p.user} + if portal_users == previous_users: + return + + portal_users = list(portal_users) + + contact = frappe.qb.DocType("Contact") + contact_email = frappe.qb.DocType("Contact Email") + + query = ( + frappe.qb.from_(contact) + .left_join(contact_email) + .on(contact_email.parent == contact.name) + .select(contact.name) + .distinct() + ) + + conditions = [ + contact.user.isin(portal_users), + Lower(contact.email_id).isin(portal_users), + Lower(contact_email.email_id).isin(portal_users), + ] + + query = query.where(Criterion.any(conditions)) + contacts = query.run(pluck=True) + + if not contacts: + return + + dynamic_link = frappe.qb.DocType("Dynamic Link") + existing_links = ( + frappe.qb.from_(dynamic_link) + .select(dynamic_link.parent) + .where( + (dynamic_link.parenttype == "Contact") + & (dynamic_link.parent.isin(contacts)) + & (dynamic_link.link_doctype == doc.doctype) + & (dynamic_link.link_name == doc.name) + ) + .run(pluck=True) + ) + + contacts_to_link = [name for name in contacts if name not in existing_links] + + for name in contacts_to_link: + contact_doc = frappe.get_doc("Contact", name) + if not contact_doc.has_link(doc.doctype, doc.name): + contact_doc.append("links", {"link_doctype": doc.doctype, "link_name": doc.name}) + contact_doc.save(ignore_permissions=True) diff --git a/erpnext/crm/doctype/appointment/appointment.json b/erpnext/crm/doctype/appointment/appointment.json index c600eb088c3..b7a92dba6d1 100644 --- a/erpnext/crm/doctype/appointment/appointment.json +++ b/erpnext/crm/doctype/appointment/appointment.json @@ -7,7 +7,11 @@ "engine": "InnoDB", "field_order": [ "scheduled_time", + "column_break_xaox", "status", + "created_through_portal", + "email_verified", + "verification_token", "customer_details_section", "customer_name", "customer_phone_number", @@ -54,7 +58,8 @@ "fieldtype": "Datetime", "in_list_view": 1, "label": "Scheduled Time", - "reqd": 1 + "reqd": 1, + "search_index": 1 }, { "fieldname": "status", @@ -77,8 +82,8 @@ "fieldname": "customer_email", "fieldtype": "Data", "label": "Email", - "reqd": 1, - "options": "Email" + "options": "Email", + "reqd": 1 }, { "fieldname": "linked_docs_section", @@ -100,13 +105,43 @@ "fieldtype": "Dynamic Link", "label": "Party", "options": "appointment_with" + }, + { + "default": "0", + "fieldname": "created_through_portal", + "fieldtype": "Check", + "label": "Created through Portal", + "read_only": 1, + "set_only_once": 1 + }, + { + "fieldname": "column_break_xaox", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:doc.created_through_portal === 1;", + "fieldname": "email_verified", + "fieldtype": "Check", + "label": "Email Verified", + "read_only": 1 + }, + { + "fieldname": "verification_token", + "fieldtype": "Data", + "label": "Verification Token", + "hidden": 1, + "read_only": 1, + "no_copy": 1, + "search_index": 1 } ], "links": [], - "modified": "2026-06-06 13:05:59.300573", + "modified": "2026-07-20 02:00:00.000000", "modified_by": "Administrator", "module": "CRM", "name": "Appointment", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -158,8 +193,9 @@ } ], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/crm/doctype/appointment/appointment.py b/erpnext/crm/doctype/appointment/appointment.py index 0f7c52688a3..da91a73f105 100644 --- a/erpnext/crm/doctype/appointment/appointment.py +++ b/erpnext/crm/doctype/appointment/appointment.py @@ -3,14 +3,20 @@ from collections import Counter +from datetime import timedelta +from urllib.parse import urlencode import frappe from frappe import _ from frappe.desk.form.assign_to import add as add_assignment from frappe.model.document import Document from frappe.share import add_docshare -from frappe.utils import get_url, getdate, now -from frappe.utils.verified_command import get_signed_params +from frappe.utils import add_to_date, cint, date_diff, get_datetime, get_url, getdate, now, now_datetime +from frappe.utils.data import sha256_hash + +from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday + +WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] class Appointment(Document): @@ -24,104 +30,227 @@ class Appointment(Document): appointment_with: DF.Link | None calendar_event: DF.Link | None + created_through_portal: DF.Check customer_details: DF.LongText | None customer_email: DF.Data customer_name: DF.Data customer_phone_number: DF.Data | None customer_skype: DF.Data | None + email_verified: DF.Check party: DF.DynamicLink | None scheduled_time: DF.Datetime status: DF.Literal["Open", "Unverified", "Closed"] + verification_token: DF.Data | None # end: auto-generated types - def find_lead_by_email(self): - lead_list = frappe.get_list( - "Lead", filters={"email_id": self.customer_email}, ignore_permissions=True - ) - if lead_list: - return lead_list[0].name - return None + def validate(self): + self.validate_status_update() + if not self.has_value_changed("scheduled_time"): + return - def find_customer_by_email(self): - customer_list = frappe.get_list( - "Customer", filters={"email_id": self.customer_email}, ignore_permissions=True + self.validate_backdated_booking() + + if is_appointment_scheduling_enabled(): + self.validate_advanced_booking() + self.validate_holiday() + self.validate_slot_timing() + + self.validate_available_time_slot() + + def validate_status_update(self): + if not self.has_value_changed("status"): + return + + if not self.created_through_portal: + if self.status == "Unverified": + frappe.throw(_("Appointments created manually cannot have 'Unverified' status.")) + return + + if self.status == "Unverified" and self.email_verified: + frappe.throw(_("A verified appointment cannot be moved back to 'Unverified' status.")) + + if self.status == "Open" and not self.email_verified: + frappe.throw( + _("An appointment booked through the portal can only be opened via email verification.") + ) + + def validate_backdated_booking(self): + if get_datetime(self.scheduled_time) < now_datetime(): + frappe.throw(_("Appointment cannot be scheduled for a past time.")) + + def validate_advanced_booking(self): + advance_booking_days = cint(get_booking_settings().advance_booking_days) + + if advance_booking_days and date_diff(self.scheduled_time, now_datetime()) > advance_booking_days: + frappe.throw( + _("Appointment can only be scheduled up to {0} day(s) in advance.").format( + advance_booking_days + ) + ) + + def validate_holiday(self): + holiday_list = get_booking_settings().holiday_list + + if not holiday_list: + frappe.throw(_("Please add a valid Holiday List on Appointment Booking Settings.")) + + if is_holiday(holiday_list, getdate(self.scheduled_time)): + frappe.throw(_("Appointment cannot be scheduled on a holiday.")) + + def validate_slot_timing(self): + settings = get_booking_settings() + if not settings.availability_of_slots: + frappe.throw(_("No availability of slots are found. Please add on Appointment Booking Settings.")) + + scheduled_time = get_datetime(self.scheduled_time) + day_of_week = WEEKDAYS[scheduled_time.weekday()] + slot_start = timedelta( + hours=scheduled_time.hour, minutes=scheduled_time.minute, seconds=scheduled_time.second ) - if customer_list: - return customer_list[0].name - return None + slot_end = slot_start + timedelta(minutes=cint(settings.appointment_duration)) + + for slot in settings.availability_of_slots: + if slot.day_of_week == day_of_week and slot.from_time <= slot_start and slot_end <= slot.to_time: + return + + frappe.throw(_("Appointment must be scheduled within the available slot timings.")) + + def validate_available_time_slot(self): + settings = get_booking_settings() + if not cint(settings.number_of_agents): + return + + # the locking read serializes concurrent bookings for the same window, + # so two simultaneous requests cannot both pass the capacity check + booked = count_overlapping_appointments( + self.scheduled_time, + cint(settings.appointment_duration), + exclude_appointment=self.name, + for_update=True, + ) + + if booked >= cint(settings.number_of_agents): + frappe.throw(_("Time slot is not available")) def before_insert(self): - number_of_appointments_in_same_slot = frappe.db.count( - "Appointment", filters={"scheduled_time": self.scheduled_time} - ) - number_of_agents = frappe.db.get_single_value("Appointment Booking Settings", "number_of_agents") - if number_of_agents != 0: - if number_of_appointments_in_same_slot >= number_of_agents: - frappe.throw(_("Time slot is not available")) - # Link lead - if not self.party: - lead = self.find_lead_by_email() - customer = self.find_customer_by_email() - if customer: - self.appointment_with = "Customer" - self.party = customer - else: - self.appointment_with = "Lead" - self.party = lead + # Set status to "Unverified" for new Appointments. + if self.created_through_portal: + self.status = "Unverified" + return + + self.link_customer_lead() def after_insert(self): - if self.party: - # Create Calendar event + if not self.created_through_portal and self.party: self.auto_assign() self.create_calendar_event() - else: - # Set status to unverified - self.db_set("status", "Unverified") - # Send email to confirm - self.send_confirmation_email() + return + + # Send email to confirm + self.send_confirmation_email() + + def on_update(self): + # capture transitions before nested saves during materialization + # refresh the before-save snapshot + status_changed = self.has_value_changed("status") + email_just_verified = bool( + self.created_through_portal and self.email_verified + ) and self.has_value_changed("email_verified") + + self.link_auto_assign_and_create_calendar_event() + + if email_just_verified: + self.send_appointment_confirmed_email() + + if status_changed: + self.update_event_and_assignments_status() + + def on_trash(self): + # the Event only references the party, not the appointment, + # so it must be cleaned up explicitly + if not self.calendar_event: + return + + event = self.calendar_event + self.db_set("calendar_event", None, update_modified=False) + frappe.delete_doc("Event", event, ignore_permissions=True) def send_confirmation_email(self): - verify_url = self._get_verify_url() - template = "confirm_appointment" - args = { - "link": verify_url, - "site_url": frappe.utils.get_url(), - "full_name": self.customer_name, - } + self.send_email_to_customer( + template="confirm_appointment", + subject=_("Appointment Confirmation"), + args={"link": self._get_verify_url(), "expiry_minutes": get_verification_link_expiry()}, + ) + frappe.msgprint(_("Please check your email to confirm the appointment.")) + + def send_appointment_confirmed_email(self): + self.send_email_to_customer( + template="appointment_confirmed", + subject=_("Appointment Confirmed"), + args={"scheduled_time": frappe.utils.format_datetime(self.scheduled_time)}, + reference_doctype="Appointment", + reference_name=self.name, + ) + + def send_email_to_customer(self, template, subject, args, **kwargs): frappe.sendmail( recipients=[self.customer_email], template=template, - args=args, - subject=_("Appointment Confirmation"), + args={"full_name": self.customer_name, "site_url": frappe.utils.get_url(), **args}, + subject=subject, + **kwargs, ) - if frappe.session.user == "Guest": - frappe.msgprint(_("Please check your email to confirm the appointment")) - else: - frappe.msgprint( - _("Appointment was created. But no lead was found. Please check the email to confirm") - ) - def on_change(self): - # Sync Calendar - if not self.calendar_event: + def link_auto_assign_and_create_calendar_event(self): + if self.is_new() or (self.created_through_portal and not self.email_verified): return + + if not self.calendar_event: + # first materialization: link the party, assign an agent, create the event + self.link_customer_lead() + self.auto_assign() + self.create_calendar_event() + + self.sync_calendar_event() + + def sync_calendar_event(self): + if not self.calendar_event or not self.has_value_changed("scheduled_time"): + return + cal_event = frappe.get_doc("Event", self.calendar_event) cal_event.starts_on = self.scheduled_time cal_event.save(ignore_permissions=True) - def set_verified(self, email): - if email != self.customer_email: - frappe.throw(_("Email verification failed.")) - # Create new lead + def update_event_and_assignments_status(self): + """Close or reopen the calendar event and assignments along with the appointment.""" + if self.status == "Unverified": + return + + is_closed = self.status == "Closed" + new_status = "Closed" if is_closed else "Open" + + if self.calendar_event: + frappe.db.set_value("Event", self.calendar_event, "status", new_status) + + # only move ToDos between Open and Closed - never touch Cancelled ones + todo_filters = { + "reference_type": "Appointment", + "reference_name": self.name, + "status": "Open" if is_closed else "Closed", + } + frappe.db.set_value("ToDo", todo_filters, "status", new_status) + + def link_customer_lead(self): + if not self.party: + customer = self.find_party_by_email("Customer") + self.appointment_with = "Customer" if customer else "Lead" + self.party = customer or self.find_party_by_email("Lead") + self.create_lead_and_link() - # Remove unverified status - self.status = "Open" - # Create calender event - self.auto_assign() - self.create_calendar_event() - self.save(ignore_permissions=True) - if not frappe.in_test: - frappe.db.commit() + + def find_party_by_email(self, doctype): + party = frappe.get_all(doctype, filters={"email_id": self.customer_email}, limit=1, pluck="name") + return party[0] if party else None def create_lead_and_link(self): # Return if already linked @@ -140,86 +269,39 @@ class Appointment(Document): if self.customer_details: lead.append( "notes", - { - "note": self.customer_details, - "added_by": frappe.session.user, - "added_on": now(), - }, + {"note": self.customer_details, "added_by": frappe.session.user, "added_on": now()}, ) - lead.insert(ignore_permissions=True) - - # Link lead - self.party = lead.name + self.party = lead.insert(ignore_permissions=True).name def auto_assign(self): - existing_assignee = self.get_assignee_from_latest_opportunity() - if existing_assignee: - # If the latest opportunity is assigned to someone - # Assign the appointment to the same - self.assign_agent(existing_assignee) - return if self._assign: return - available_agents = _get_agents_sorted_by_asc_workload(getdate(self.scheduled_time)) - for agent in available_agents: - if _check_agent_availability(agent, self.scheduled_time): - self.assign_agent(agent[0]) - break + + if existing_assignee := self.get_assignee_from_latest_opportunity(): + # assign to whoever handles the party's latest opportunity + self.assign_agent(existing_assignee) + return + + busy_agents = get_busy_agents(self.scheduled_time) + for agent in _get_agents_sorted_by_asc_workload(getdate(self.scheduled_time)): + if agent not in busy_agents: + self.assign_agent(agent) + break def get_assignee_from_latest_opportunity(self): - if not self.party: + if not self.party or not frappe.db.exists("Lead", self.party): return None - if not frappe.db.exists("Lead", self.party): - return None - opporutnities = frappe.get_list( + + opportunities = frappe.get_all( "Opportunity", - filters={ - "party_name": self.party, - }, - ignore_permissions=True, + filters={"party_name": self.party}, + fields=["_assign"], order_by="creation desc", + limit=1, ) - if not opporutnities: - return None - latest_opportunity = frappe.get_doc("Opportunity", opporutnities[0].name) - assignee = latest_opportunity._assign - if not assignee: - return None - assignee = frappe.parse_json(assignee)[0] - return assignee - - def create_calendar_event(self): - if self.calendar_event: - return - appointment_event = frappe.get_doc( - { - "doctype": "Event", - "subject": " ".join(["Appointment with", self.customer_name]), - "starts_on": self.scheduled_time, - "status": "Open", - "type": "Public", - "send_reminder": frappe.db.get_single_value( - "Appointment Booking Settings", "email_reminders" - ), - "event_participants": [ - dict(reference_doctype=self.appointment_with, reference_docname=self.party) - ], - } - ) - employee = _get_employee_from_user(self._assign) - if employee: - appointment_event.append( - "event_participants", dict(reference_doctype="Employee", reference_docname=employee.name) - ) - appointment_event.insert(ignore_permissions=True) - self.calendar_event = appointment_event.name - self.save(ignore_permissions=True) - - def _get_verify_url(self): - verify_route = "/book_appointment/verify" - params = {"email": self.customer_email, "appointment": self.name} - return get_url(verify_route + "?" + get_signed_params(params)) + assignees = opportunities and frappe.parse_json(opportunities[0]._assign or "[]") + return assignees[0] if assignees else None def assign_agent(self, agent): if not frappe.has_permission(doc=self, user=agent): @@ -227,45 +309,157 @@ class Appointment(Document): add_assignment({"doctype": self.doctype, "name": self.name, "assign_to": [agent]}) + def create_calendar_event(self): + if self.calendar_event: + return + + event = frappe.get_doc( + { + "doctype": "Event", + "subject": f"Appointment with {self.customer_name}", + "starts_on": self.scheduled_time, + "status": "Open", + "type": "Public", + "send_reminder": cint(get_booking_settings().email_reminders), + "event_participants": self.get_event_participants(), + } + ).insert(ignore_permissions=True) + + self.calendar_event = event.name + self.save(ignore_permissions=True) + + def get_event_participants(self): + participants = [dict(reference_doctype=self.appointment_with, reference_docname=self.party)] + + if employee := _get_employee_from_user(self._assign): + participants.append(dict(reference_doctype="Employee", reference_docname=employee.name)) + + return participants + + def _get_verify_url(self): + key = self.generate_verification_key() + return get_url("/book_appointment/verify?" + urlencode({"key": key})) + + def generate_verification_key(self): + # store only the hash; the raw key lives solely in the emailed link + key = frappe.generate_hash() + self.db_set("verification_token", sha256_hash(key), update_modified=False) + return key + + +def get_booking_settings(): + return frappe.get_cached_doc("Appointment Booking Settings") + + +def is_appointment_scheduling_enabled(): + return bool(cint(get_booking_settings().enable_scheduling)) + + +def get_verification_link_expiry(): + """Verification link expiry window in minutes.""" + return cint(get_booking_settings().verification_link_expiry_duration) + + +def count_overlapping_appointments( + scheduled_time, appointment_duration, exclude_appointment=None, for_update=False +): + """Count non-Closed appointments whose duration window overlaps `scheduled_time`. + With `for_update`, the range stays locked until commit, serializing concurrent bookings.""" + # select the rows (not COUNT) so `for_update` stays valid: PostgreSQL + # rejects `FOR UPDATE` combined with an aggregate function + appointment = frappe.qb.DocType("Appointment") + query = ( + frappe.qb.from_(appointment) + .select(appointment.name) + .where(appointment.scheduled_time > add_to_date(scheduled_time, minutes=-appointment_duration)) + .where(appointment.scheduled_time < add_to_date(scheduled_time, minutes=appointment_duration)) + .where(appointment.status != "Closed") + ) + + if exclude_appointment: + query = query.where(appointment.name != exclude_appointment) + + if for_update: + query = query.for_update() + + return len(query.run()) + + +def handle_expired_unverified_appointments(): + """Close or delete Unverified appointments whose verification link has expired.""" + expiry = get_verification_link_expiry() + if not expiry: + return + + cutoff = add_to_date(now_datetime(), minutes=-expiry) + filters = {"status": "Unverified", "creation": ("<", cutoff)} + action = get_booking_settings().action_for_expired_unverified_appointments or "Mark as Closed" + + if action == "Mark as Closed": + frappe.db.set_value("Appointment", filters, "status", "Closed") + elif action == "Delete Permanently": + for name in frappe.get_all("Appointment", filters=filters, pluck="name"): + frappe.delete_doc("Appointment", name, ignore_permissions=True) + def _get_agents_sorted_by_asc_workload(date): - appointments = frappe.get_all("Appointment", fields="*") - agent_list = _get_agent_list_as_strings() - if not appointments: - return agent_list - appointment_counter = Counter(agent_list) - for appointment in appointments: - assign_data = appointment._assign - if isinstance(assign_data, str): - assign_data = assign_data.strip() - if not assign_data: - continue - assigned_to = frappe.parse_json(assign_data) - if assigned_to and (assigned_to[0] in agent_list) and getdate(appointment.scheduled_time) == date: - appointment_counter[assigned_to[0]] += 1 - sorted_agent_list = appointment_counter.most_common() - sorted_agent_list.reverse() - return sorted_agent_list + # count only the given day's assignments; scheduled_time is indexed so the + # date range is resolved in SQL instead of scanning every appointment ever + workload = Counter(agent.user for agent in get_booking_settings().agent_list) + assigns = frappe.get_all( + "Appointment", + filters=[ + ["_assign", "is", "set"], + ["scheduled_time", ">=", getdate(date)], + ["scheduled_time", "<", add_to_date(getdate(date), days=1)], + ], + pluck="_assign", + ) + + for assign in assigns: + assignees = frappe.parse_json((assign or "").strip() or "[]") + if assignees and assignees[0] in workload: + workload[assignees[0]] += 1 + + return [agent for agent, _workload in reversed(workload.most_common())] -def _get_agent_list_as_strings(): - agent_list_as_strings = [] - agent_list = frappe.get_doc("Appointment Booking Settings").agent_list - for agent in agent_list: - agent_list_as_strings.append(agent.user) - return agent_list_as_strings +def get_busy_agents(scheduled_time): + """Agents already assigned to a non-Closed appointment overlapping `scheduled_time`.""" + duration = _get_appointment_duration() + assigns = frappe.get_all( + "Appointment", + filters=[ + ["scheduled_time", ">", add_to_date(scheduled_time, minutes=-duration)], + ["scheduled_time", "<", add_to_date(scheduled_time, minutes=duration)], + ["status", "!=", "Closed"], + ], + pluck="_assign", + ) + return {assignee for assign in assigns for assignee in frappe.parse_json(assign or "[]")} def _check_agent_availability(agent_email, scheduled_time): - appointemnts_at_scheduled_time = frappe.get_all("Appointment", filters={"scheduled_time": scheduled_time}) - for appointment in appointemnts_at_scheduled_time: - if appointment._assign == agent_email: - return False - return True + return agent_email not in get_busy_agents(scheduled_time) + + +def get_booked_slot_times(from_time, to_time): + """scheduled_times of non-Closed appointments within (from_time, to_time), for slot availability.""" + return frappe.get_all( + "Appointment", + filters=[ + ["scheduled_time", ">", from_time], + ["scheduled_time", "<", to_time], + ["status", "!=", "Closed"], + ], + pluck="scheduled_time", + ) + + +def _get_appointment_duration(): + return cint(get_booking_settings().appointment_duration) def _get_employee_from_user(user): employee_docname = frappe.db.get_value("Employee", {"user_id": user}) - if employee_docname: - return frappe.get_doc("Employee", employee_docname) - return None + return frappe.get_doc("Employee", employee_docname) if employee_docname else None diff --git a/erpnext/crm/doctype/appointment/test_appointment.py b/erpnext/crm/doctype/appointment/test_appointment.py index 83eacca83b6..80c0ced648e 100644 --- a/erpnext/crm/doctype/appointment/test_appointment.py +++ b/erpnext/crm/doctype/appointment/test_appointment.py @@ -1,36 +1,167 @@ # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt import datetime +from unittest.mock import patch +from urllib.parse import parse_qs, urlparse import frappe +from frappe.utils import add_to_date, getdate, now_datetime, set_request +from frappe.utils.data import sha256_hash +from erpnext.crm.doctype.appointment.appointment import ( + Appointment, + _check_agent_availability, + handle_expired_unverified_appointments, +) +from erpnext.setup.doctype.holiday_list.test_holiday_list import make_holiday_list from erpnext.tests.utils import ERPNextTestSuite +from erpnext.www.book_appointment.index import create_appointment, get_appointment_slots +from erpnext.www.book_appointment.verify import index as verify_index LEAD_EMAIL = "test_appointment_lead@example.com" +VERIFICATION_EXPIRY_MINUTES = 30 +ALL_WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] -def create_test_appointment(): - test_appointment = frappe.get_doc( - { - "doctype": "Appointment", - "status": "Open", - "customer_name": "Test Lead", - "customer_phone_number": "666", - "customer_skype": "test", - "customer_email": LEAD_EMAIL, - "scheduled_time": datetime.datetime.now(), - "customer_details": "Hello, Friend!", - } - ) +def create_test_appointment(**kwargs): + args = { + "doctype": "Appointment", + "status": "Open", + "customer_name": "Test Lead", + "customer_phone_number": "666", + "customer_skype": "test", + "customer_email": LEAD_EMAIL, + "scheduled_time": add_to_date(now_datetime(), hours=2), + "customer_details": "Hello, Friend!", + } + args.update(kwargs) + test_appointment = frappe.get_doc(args) test_appointment.insert() return test_appointment +def create_lead(email, name="Existing Lead"): + frappe.db.delete("Lead", {"email_id": email}) + return frappe.get_doc({"doctype": "Lead", "lead_name": name, "email_id": email}).insert( + ignore_permissions=True + ) + + +def set_booking_setting(field, value): + frappe.db.set_single_value("Appointment Booking Settings", field, value) + + +def slot_on(days_from_now, hour, minute=0): + day = datetime.date.today() + datetime.timedelta(days=days_from_now) + return datetime.datetime.combine(day, datetime.time(hour, minute)) + + +def backdate_creation(appointment_name, minutes): + frappe.db.set_value( + "Appointment", + appointment_name, + "creation", + add_to_date(now_datetime(), minutes=-minutes), + update_modified=False, + ) + + +def get_status(appointment_name): + return frappe.db.get_value("Appointment", appointment_name, "status") + + +def get_assignees(appointment_name): + return frappe.parse_json(frappe.db.get_value("Appointment", appointment_name, "_assign") or "[]") + + +def get_todo_statuses(appointment_name): + return frappe.get_all( + "ToDo", + filters={"reference_type": "Appointment", "reference_name": appointment_name}, + pluck="status", + ) + + +def parse_verify_url(verify_url): + parsed = urlparse(verify_url) + return parsed, {key: value[0] for key, value in parse_qs(parsed.query).items()} + + class TestAppointment(ERPNextTestSuite): def setUp(self): + set_booking_setting("verification_link_expiry_duration", VERIFICATION_EXPIRY_MINUTES) frappe.db.delete("Lead", {"email_id": LEAD_EMAIL}) self.test_appointment = create_test_appointment() - self.test_appointment.set_verified(self.test_appointment.customer_email) + + def _configure_booking_settings(self, holiday_dates=None, agents=None): + holiday_list = make_holiday_list( + "_Test Appointment Holiday List", + from_date=getdate(), + to_date=add_to_date(getdate(), days=60), + holiday_dates=holiday_dates or [], + ) + + settings = frappe.get_doc("Appointment Booking Settings") + settings.enable_scheduling = 1 + settings.enable_appointment_portal = 1 + settings.appointment_duration = 30 + settings.advance_booking_days = 30 + settings.verification_link_expiry_duration = VERIFICATION_EXPIRY_MINUTES + settings.holiday_list = holiday_list.name + settings.set("agent_list", []) + for agent in agents or ["Administrator"]: + settings.append("agent_list", {"user": agent}) + settings.set("availability_of_slots", []) + for day in ALL_WEEKDAYS: + settings.append( + "availability_of_slots", {"day_of_week": day, "from_time": "09:00:00", "to_time": "17:00:00"} + ) + settings.save() + + def _create_portal_appointment(self, email, days_from_now=7, time="10:00:00"): + """Book as Guest. The verification email is mocked and kept on + ``self._verification_email_mock`` for assertions.""" + if not getattr(self, "_booking_settings_configured", False): + self._configure_booking_settings() + self._booking_settings_configured = True + + with self.set_user("Guest"), patch.object(Appointment, "send_confirmation_email") as mock_send: + appointment = create_appointment( + date=str(datetime.date.today() + datetime.timedelta(days=days_from_now)), + time=time, + tz="UTC", + contact={"name": "Portal Visitor", "email": email, "number": "123", "skype": "", "notes": ""}, + ) + self._verification_email_mock = mock_send + return appointment + + def _request_verification(self, appointment, verify_url=None): + """Simulate the GET request made by clicking the emailed verification link. + + The confirmation email sent on successful verification is mocked and kept + on ``self._confirmed_email_mock`` for assertions. + """ + parsed, params = parse_verify_url(verify_url or appointment._get_verify_url()) + + old_request = getattr(frappe.local, "request", None) + old_form_dict = frappe.local.form_dict + old_user = frappe.session.user + try: + # the real link is clicked by an anonymous visitor; set_user resets + # form_dict, so switch the user before populating the request + frappe.set_user("Guest") + set_request(method="GET", path=f"{parsed.path}?{parsed.query}") + frappe.local.form_dict = frappe._dict(params) + context = frappe._dict() + with patch.object(Appointment, "send_appointment_confirmed_email") as mock_confirmed: + verify_index.get_context(context) + self._confirmed_email_mock = mock_confirmed + return context + finally: + frappe.set_user(old_user) + frappe.local.request = old_request + frappe.local.form_dict = old_form_dict + frappe.local.flags.commit = False def test_calendar_event_created(self): cal_event = frappe.get_doc("Event", self.test_appointment.calendar_event) @@ -38,3 +169,371 @@ class TestAppointment(ERPNextTestSuite): def test_lead_linked(self): self.assertTrue(self.test_appointment.party) + + def test_desk_created_appointment_skips_email_verification(self): + """Appointments created from the desk (created_through_portal unset) must be + linked and confirmed immediately - no verification email should be sent.""" + with patch.object(Appointment, "send_confirmation_email") as mock_send: + appointment = create_test_appointment(customer_email="another_desk_lead@example.com") + + mock_send.assert_not_called() + self.assertEqual(appointment.status, "Open") + self.assertTrue(appointment.party) + frappe.db.delete("Lead", {"email_id": "another_desk_lead@example.com"}) + + def test_portal_booking_stays_unverified_for_existing_lead(self): + """A portal booking whose email matches an existing Lead/Customer must NOT + be auto-linked - it must stay Unverified until the email is confirmed.""" + create_lead("existing_lead@example.com") + appointment = self._create_portal_appointment("existing_lead@example.com", days_from_now=5) + + self._verification_email_mock.assert_called_once() + self.assertTrue(appointment.created_through_portal) + self.assertEqual(appointment.status, "Unverified") + self.assertFalse(appointment.email_verified) + self.assertFalse(appointment.party) + + def test_verify_url_uses_opaque_token(self): + appointment = self._create_portal_appointment("portal_visitor@example.com") + parsed, params = parse_verify_url(appointment._get_verify_url()) + + # the link carries only an opaque key - no email, name or signed params + self.assertEqual(set(params), {"key"}) + self.assertNotIn("email", parsed.query) + # only the hash of that key is stored on the appointment + stored = frappe.db.get_value("Appointment", appointment.name, "verification_token") + self.assertEqual(stored, sha256_hash(params["key"])) + + def test_email_verification_within_expiry_window(self): + # Link used within the validity window - verification succeeds and the + # appointment gets linked, assigned and added to the calendar + on_time = self._create_portal_appointment("portal_visitor_on_time@example.com") + context = self._request_verification(on_time) + + self.assertTrue(context.success) + self._confirmed_email_mock.assert_called_once() + on_time.reload() + self.assertEqual(on_time.status, "Open") + self.assertTrue(on_time.email_verified) + self.assertTrue(on_time.party) + self.assertTrue(on_time.calendar_event) + + # Link used after the validity window - verification fails + late = self._create_portal_appointment("portal_visitor_late@example.com", days_from_now=10) + after_expiry = add_to_date(now_datetime(), minutes=VERIFICATION_EXPIRY_MINUTES + 1) + with patch.object(verify_index, "now_datetime", return_value=after_expiry): + context = self._request_verification(late) + + self.assertFalse(context.success) + self._confirmed_email_mock.assert_not_called() + late.reload() + self.assertEqual(late.status, "Unverified") + self.assertFalse(late.email_verified) + self.assertFalse(late.party) + + def test_verification_link_reused_after_success(self): + appointment = self._create_portal_appointment("portal_visitor_twice@example.com") + verify_url = appointment._get_verify_url() + + context = self._request_verification(appointment, verify_url=verify_url) + self.assertTrue(context.success) + self._confirmed_email_mock.assert_called_once() + + # re-clicking the link is idempotent and does not send another email + context = self._request_verification(appointment, verify_url=verify_url) + self.assertTrue(context.success) + self.assertIn("already verified", context.message) + self._confirmed_email_mock.assert_not_called() + + def test_verification_link_for_deleted_appointment(self): + """A verification link can outlive its appointment - clicking it must + render a friendly message, not crash.""" + appointment = self._create_portal_appointment("portal_visitor_gone@example.com") + verify_url = appointment._get_verify_url() + frappe.delete_doc("Appointment", appointment.name, ignore_permissions=True) + + context = self._request_verification(appointment, verify_url=verify_url) + + self.assertFalse(context.success) + self.assertIn("book the appointment again", context.message) + + def test_reschedule_syncs_calendar_event(self): + new_time = add_to_date(self.test_appointment.scheduled_time, hours=1) + self.test_appointment.scheduled_time = new_time + self.test_appointment.save() + + starts_on = frappe.db.get_value("Event", self.test_appointment.calendar_event, "starts_on") + self.assertEqual(starts_on, new_time) + + def test_portal_endpoint_disabled(self): + self._configure_booking_settings() + set_booking_setting("enable_appointment_portal", 0) + + with self.set_user("Guest"), self.assertRaises(frappe.Redirect): + create_appointment( + date=str(datetime.date.today() + datetime.timedelta(days=3)), + time="10:00:00", + tz="UTC", + contact={ + "name": "Blocked", + "email": "blocked@example.com", + "number": "1", + "skype": "", + "notes": "", + }, + ) + + def test_booked_slot_unavailable_on_portal(self): + from frappe.utils.data import get_system_timezone + + self._configure_booking_settings() + tz = get_system_timezone() + day = datetime.date.today() + datetime.timedelta(days=2) + + def get_availability(): + with self.set_user("Guest"): + slots = get_appointment_slots(str(day), tz) + return {slot["time"].strftime("%H:%M"): slot["availability"] for slot in slots} + + booked = create_test_appointment( + customer_email="slot_taken@example.com", scheduled_time=slot_on(2, 10) + ) + + availability = get_availability() + self.assertFalse(availability["10:00"]) + self.assertTrue(availability["13:00"]) + + # closing the appointment frees its slot on the portal + booked.status = "Closed" + booked.save() + self.assertTrue(get_availability()["10:00"]) + + # an off-grid desk appointment blocks every portal slot it overlaps + create_test_appointment(customer_email="off_grid@example.com", scheduled_time=slot_on(2, 13, 15)) + availability = get_availability() + self.assertFalse(availability["13:00"]) + self.assertFalse(availability["13:30"]) + self.assertTrue(availability["14:00"]) + + def test_expired_unverified_appointments_are_closed(self): + stale = self._create_portal_appointment("portal_visitor_stale@example.com", days_from_now=8) + fresh = self._create_portal_appointment("portal_visitor_fresh@example.com", days_from_now=9) + verify_url = stale._get_verify_url() + + backdate_creation(stale.name, VERIFICATION_EXPIRY_MINUTES + 15) + set_booking_setting("action_for_expired_unverified_appointments", "Mark as Closed") + + handle_expired_unverified_appointments() + + self.assertEqual(get_status(stale.name), "Closed") + self.assertEqual(get_status(fresh.name), "Unverified") + # Open appointments are never touched, regardless of age + self.assertEqual(get_status(self.test_appointment.name), "Open") + + # clicking the link of a closed appointment renders a friendly message + context = self._request_verification(stale, verify_url=verify_url) + self.assertFalse(context.success) + self.assertIn("closed", context.message) + + def test_expired_unverified_appointments_are_deleted(self): + stale = self._create_portal_appointment("portal_visitor_purged@example.com", days_from_now=8) + fresh = self._create_portal_appointment("portal_visitor_kept@example.com", days_from_now=9) + + backdate_creation(stale.name, VERIFICATION_EXPIRY_MINUTES + 15) + set_booking_setting("action_for_expired_unverified_appointments", "Delete Permanently") + + handle_expired_unverified_appointments() + + self.assertFalse(frappe.db.exists("Appointment", stale.name)) + self.assertTrue(frappe.db.exists("Appointment", fresh.name)) + self.assertTrue(frappe.db.exists("Appointment", self.test_appointment.name)) + + def test_cleanup_skipped_when_expiry_not_configured(self): + appointment = self._create_portal_appointment("portal_visitor_no_expiry@example.com") + backdate_creation(appointment.name, 5) + set_booking_setting("verification_link_expiry_duration", 0) + + handle_expired_unverified_appointments() + + self.assertEqual(get_status(appointment.name), "Unverified") + + def test_status_transition_rules(self): + # desk appointments can never be Unverified + with self.assertRaises(frappe.ValidationError): + create_test_appointment(customer_email="desk_unverified@example.com", status="Unverified") + + # portal appointments cannot be opened manually before verification + unverified = self._create_portal_appointment("manual_open@example.com") + unverified.status = "Open" + with self.assertRaises(frappe.ValidationError): + unverified.save(ignore_permissions=True) + + # verified appointments cannot be reverted to Unverified + verified = self._create_portal_appointment("revert_unverified@example.com", days_from_now=8) + self._request_verification(verified) + verified.reload() + verified.status = "Unverified" + with self.assertRaises(frappe.ValidationError): + verified.save(ignore_permissions=True) + + # both desk and verified portal appointments can be closed and reopened + for appointment in (self.test_appointment, verified): + appointment.reload() + appointment.status = "Closed" + appointment.save(ignore_permissions=True) + appointment.status = "Open" + appointment.save(ignore_permissions=True) + self.assertEqual(appointment.status, "Open") + + def test_agent_auto_assignment(self): + agent_email = "appointment_agent@example.com" + if not frappe.db.exists("User", agent_email): + frappe.get_doc( + {"doctype": "User", "email": agent_email, "first_name": "Appointment Agent"} + ).insert(ignore_permissions=True) + + self._configure_booking_settings(agents=["Administrator", agent_email]) + first = create_test_appointment( + customer_email="assigned_one@example.com", scheduled_time=slot_on(2, 11) + ) + second = create_test_appointment( + customer_email="assigned_two@example.com", scheduled_time=slot_on(2, 11) + ) + + # both appointments in the same slot get an agent, and never the same one + self.assertTrue(get_assignees(first.name)) + self.assertTrue(get_assignees(second.name)) + self.assertNotEqual(get_assignees(first.name), get_assignees(second.name)) + + # closing an assigned appointment closes its ToDo without re-assigning + first.reload() + first.status = "Closed" + first.save() + self.assertTrue(get_todo_statuses(first.name)) + self.assertTrue(all(status == "Closed" for status in get_todo_statuses(first.name))) + + # reopening brings the ToDos back + first.status = "Open" + first.save() + self.assertTrue(all(status == "Open" for status in get_todo_statuses(first.name))) + + def test_agent_busy_for_the_whole_appointment_duration(self): + self._configure_booking_settings() + slot = slot_on(3, 11) + appointment = create_test_appointment(customer_email="busy_agent@example.com", scheduled_time=slot) + assignee = get_assignees(appointment.name)[0] + + # busy anywhere inside the 30-minute appointment window, free right after it + self.assertFalse(_check_agent_availability(assignee, slot)) + self.assertFalse(_check_agent_availability(assignee, slot + datetime.timedelta(minutes=15))) + self.assertTrue(_check_agent_availability(assignee, slot + datetime.timedelta(minutes=30))) + + def test_closed_appointment_closes_calendar_event(self): + self.test_appointment.status = "Closed" + self.test_appointment.save() + event_status = frappe.db.get_value("Event", self.test_appointment.calendar_event, "status") + self.assertEqual(event_status, "Closed") + + # reopening the appointment reopens the calendar event + self.test_appointment.status = "Open" + self.test_appointment.save() + event_status = frappe.db.get_value("Event", self.test_appointment.calendar_event, "status") + self.assertEqual(event_status, "Open") + + def test_deleting_appointment_deletes_calendar_event(self): + event = self.test_appointment.calendar_event + self.assertTrue(frappe.db.exists("Event", event)) + + frappe.delete_doc("Appointment", self.test_appointment.name) + + self.assertFalse(frappe.db.exists("Event", event)) + + def test_backdated_appointment_is_rejected(self): + with self.assertRaises(frappe.ValidationError): + create_test_appointment( + customer_email="backdated@example.com", + scheduled_time=add_to_date(now_datetime(), hours=-1), + ) + + def test_booking_beyond_advance_window_is_rejected(self): + self._configure_booking_settings() + set_booking_setting("advance_booking_days", 7) + + # within the advance booking window - allowed + within = create_test_appointment( + customer_email="advance_within@example.com", scheduled_time=slot_on(5, 10) + ) + self.assertTrue(frappe.db.exists("Appointment", within.name)) + + # beyond the advance booking window - rejected + with self.assertRaises(frappe.ValidationError): + create_test_appointment( + customer_email="advance_beyond@example.com", scheduled_time=slot_on(8, 10) + ) + + def test_appointment_on_holiday_is_rejected(self): + holiday = add_to_date(getdate(), days=3) + self._configure_booking_settings( + holiday_dates=[{"holiday_date": holiday, "description": "Test Holiday"}] + ) + + with self.assertRaises(frappe.ValidationError): + create_test_appointment(customer_email="on_holiday@example.com", scheduled_time=slot_on(3, 10)) + + # the day after the holiday is bookable + after_holiday = create_test_appointment( + customer_email="after_holiday@example.com", scheduled_time=slot_on(4, 10) + ) + self.assertTrue(frappe.db.exists("Appointment", after_holiday.name)) + + def test_appointment_outside_slot_timing_is_rejected(self): + self._configure_booking_settings() + + # before the slot opens + with self.assertRaises(frappe.ValidationError): + create_test_appointment(customer_email="before_opening@example.com", scheduled_time=slot_on(2, 8)) + + # starts within the slot but would end after it closes + with self.assertRaises(frappe.ValidationError): + create_test_appointment( + customer_email="past_closing@example.com", scheduled_time=slot_on(2, 16, 45) + ) + + # within the slot timings + within = create_test_appointment( + customer_email="within_slot@example.com", scheduled_time=slot_on(2, 10) + ) + self.assertTrue(frappe.db.exists("Appointment", within.name)) + + def test_overlapping_time_slot_capacity(self): + set_booking_setting("number_of_agents", 1) + set_booking_setting("appointment_duration", 30) + + slot = slot_on(1, 10) + first = create_test_appointment(customer_email="slot_first@example.com", scheduled_time=slot) + + # a booking starting inside the first appointment's duration is rejected + with self.assertRaises(frappe.ValidationError): + create_test_appointment( + customer_email="slot_overlap@example.com", + scheduled_time=slot + datetime.timedelta(minutes=15), + ) + + # rescheduling must not count the appointment's own booked slot + first.scheduled_time = slot + datetime.timedelta(minutes=10) + first.save() + + # a booking starting exactly when the rescheduled one ends is allowed + adjacent = create_test_appointment( + customer_email="slot_adjacent@example.com", + scheduled_time=slot + datetime.timedelta(minutes=40), + ) + self.assertTrue(frappe.db.exists("Appointment", adjacent.name)) + + # a closed (cancelled) appointment frees its slot + first.status = "Closed" + first.save() + after_cancellation = create_test_appointment( + customer_email="after_cancellation@example.com", scheduled_time=slot + ) + self.assertTrue(frappe.db.exists("Appointment", after_cancellation.name)) diff --git a/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.json b/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.json index b79e974e301..8557dcf8791 100644 --- a/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.json +++ b/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.json @@ -1,48 +1,56 @@ { "actions": [], + "allow_bulk_edit": 1, "creation": "2019-08-27 10:56:48.309824", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "enable_scheduling", - "agent_detail_section", - "availability_of_slots", - "number_of_agents", - "agent_list", - "holiday_list", "appointment_details_section", "appointment_duration", "email_reminders", + "column_break_ehiq", + "agent_list", + "number_of_agents", + "agent_detail_section", + "enable_scheduling", + "availability_of_slots", + "section_break_bkln", + "column_break_alwa", "advance_booking_days", + "column_break_bspp", + "holiday_list", "success_details", - "success_redirect_url" + "enable_appointment_portal", + "verification_link_expiry_duration", + "column_break_fovk", + "success_redirect_url", + "action_for_expired_unverified_appointments" ], "fields": [ { + "depends_on": "eval:doc.enable_scheduling === 1;", "fieldname": "availability_of_slots", "fieldtype": "Table", "label": "Availability Of Slots", - "options": "Appointment Booking Slots", - "reqd": 1 + "mandatory_depends_on": "eval:doc.enable_scheduling === 1;", + "options": "Appointment Booking Slots" }, { - "default": "1", "fieldname": "number_of_agents", "fieldtype": "Int", - "hidden": 1, "in_list_view": 1, "label": "Number of Concurrent Appointments", - "read_only": 1, - "reqd": 1 + "read_only": 1 }, { + "depends_on": "eval:doc.enable_scheduling === 1;", "fieldname": "holiday_list", "fieldtype": "Link", "in_list_view": 1, "label": "Holiday List", - "options": "Holiday List", - "reqd": 1 + "mandatory_depends_on": "eval:doc.enable_scheduling === 1;", + "options": "Holiday List" }, { "default": "60", @@ -60,29 +68,31 @@ }, { "default": "7", + "depends_on": "eval:doc.enable_scheduling === 1;", "fieldname": "advance_booking_days", "fieldtype": "Int", "label": "Number of days appointments can be booked in advance", - "reqd": 1 + "mandatory_depends_on": "eval:doc.enable_scheduling === 1;" }, { "fieldname": "agent_list", "fieldtype": "Table MultiSelect", "label": "Agents", - "options": "Assignment Rule User", - "reqd": 1 + "mandatory_depends_on": "eval:doc.enable_scheduling === 1;", + "options": "Assignment Rule User" }, { "default": "0", "fieldname": "enable_scheduling", "fieldtype": "Check", "label": "Enable Appointment Scheduling", - "reqd": 1 + "mandatory_depends_on": "eval:doc.enable_appointment_portal === 1;" }, { "fieldname": "agent_detail_section", "fieldtype": "Section Break", - "label": "Agent Details" + "hide_border": 1, + "label": "Appointment Scheduling" }, { "fieldname": "appointment_details_section", @@ -92,20 +102,68 @@ { "fieldname": "success_details", "fieldtype": "Section Break", - "label": "Success Settings" + "label": "Appointment Booking Portal Settings" }, { "description": "Leave blank for home.\nThis is relative to site URL, for example \"about\" will redirect to \"https://yoursitename.com/about\"", "fieldname": "success_redirect_url", "fieldtype": "Data", - "label": "Success Redirect URL" + "label": "Success Redirect URL", + "permlevel": 1 + }, + { + "default": "30", + "depends_on": "eval: doc.enable_scheduling === 1;", + "description": "In Minutes (min: 15 mins, max: 60 mins)", + "fieldname": "verification_link_expiry_duration", + "fieldtype": "Int", + "label": "Verification Link Expiry Duration", + "mandatory_depends_on": "eval:doc.enable_appointment_portal === 1;", + "max_value": 60.0, + "min_value": 15.0, + "non_negative": 1, + "permlevel": 1 + }, + { + "fieldname": "column_break_ehiq", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "enable_appointment_portal", + "fieldtype": "Check", + "label": "Enable Appointment Booking Through Portal", + "permlevel": 1 + }, + { + "fieldname": "column_break_fovk", + "fieldtype": "Column Break" + }, + { + "default": "Mark as Closed", + "fieldname": "action_for_expired_unverified_appointments", + "fieldtype": "Select", + "label": "Action for Expired Unverified Appointments", + "options": "Mark as Closed\nDelete Permanently", + "permlevel": 1 + }, + { + "fieldname": "section_break_bkln", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_alwa", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_bspp", + "fieldtype": "Column Break" } ], "grid_page_length": 50, - "hide_toolbar": 0, "issingle": 1, "links": [], - "modified": "2026-03-16 13:28:21.198138", + "modified": "2026-07-20 00:11:18.996384", "modified_by": "Administrator", "module": "CRM", "name": "Appointment Booking Settings", @@ -139,6 +197,15 @@ "role": "Sales Manager", "share": 1, "write": 1 + }, + { + "email": 1, + "permlevel": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 } ], "quick_entry": 1, diff --git a/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.py b/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.py index 36eb21f0441..2d7b6cd3f7d 100644 --- a/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.py +++ b/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.py @@ -3,11 +3,11 @@ import datetime -import typing import frappe from frappe import _ from frappe.model.document import Document +from frappe.utils import getdate class AppointmentBookingSettings(Document): @@ -24,33 +24,43 @@ class AppointmentBookingSettings(Document): AppointmentBookingSlots, ) + action_for_expired_unverified_appointments: DF.Literal["Mark as Closed", "Delete Permanently"] advance_booking_days: DF.Int agent_list: DF.TableMultiSelect[AssignmentRuleUser] appointment_duration: DF.Int availability_of_slots: DF.Table[AppointmentBookingSlots] email_reminders: DF.Check + enable_appointment_portal: DF.Check enable_scheduling: DF.Check - holiday_list: DF.Link + holiday_list: DF.Link | None number_of_agents: DF.Int success_redirect_url: DF.Data | None + verification_link_expiry_duration: DF.Int # end: auto-generated types - agent_list: typing.ClassVar[list] = [] # Hack - min_date = "01/01/1970 " - format_string = "%d/%m/%Y %H:%M:%S" - def validate(self): - self.validate_availability_of_slots() - - def save(self): self.number_of_agents = len(self.agent_list) - super().save() + self.validate_appointment_scheduling() + self.validate_portal_booking() + + def validate_appointment_scheduling(self): + if not self.enable_scheduling: + return + + self.validate_availability_of_slots() + self.validate_holiday_list() + self.validate_advance_booking_days() def validate_availability_of_slots(self): + if not self.availability_of_slots: + frappe.throw( + _("Please fill up the Availability of Slots table to enable Appointment Scheduling.") + ) + + format_string = "%Y-%m-%d %H:%M:%S" for record in self.availability_of_slots: - from_time = datetime.datetime.strptime(self.min_date + record.from_time, self.format_string) - to_time = datetime.datetime.strptime(self.min_date + record.to_time, self.format_string) - to_time - from_time + from_time = datetime.datetime.strptime(f"1970-01-01 {record.from_time}", format_string) + to_time = datetime.datetime.strptime(f"1970-01-01 {record.to_time}", format_string) self.validate_from_and_to_time(from_time, to_time, record) self.duration_is_divisible(from_time, to_time) @@ -65,3 +75,38 @@ class AppointmentBookingSettings(Document): timedelta = to_time - from_time if timedelta.total_seconds() % (self.appointment_duration * 60): frappe.throw(_("The difference between from time and To Time must be a multiple of Appointment")) + + def validate_holiday_list(self): + if not self.holiday_list: + frappe.throw(_("Please select a Holiday List to enable Appointment Scheduling.")) + + hl_from_date, hl_to_date = frappe.get_cached_value( + "Holiday List", self.holiday_list, ["from_date", "to_date"] + ) + now = getdate() + + if not (now >= hl_from_date and now <= hl_to_date): + frappe.throw(_("Holiday List - {0} is not valid for current date.").format(self.holiday_list)) + + def validate_advance_booking_days(self): + if not self.advance_booking_days: + frappe.throw(_("Advance Booking Days is mandatory for Appointment Scheduling.")) + + def validate_portal_booking(self): + if not self.enable_appointment_portal: + return + + if not self.enable_scheduling: + frappe.throw( + _("Appointment Scheduling needs to be enabled for Appointment Booking through portal.") + ) + + self.validate_link_expiry_duration() + + def validate_link_expiry_duration(self): + if ( + not self.verification_link_expiry_duration + or self.verification_link_expiry_duration > 60 + or self.verification_link_expiry_duration < 15 + ): + frappe.throw(_("'Verification Link Expiry Duration' must be between 15 to 60 minutes.")) diff --git a/erpnext/crm/doctype/appointment_booking_settings/test_appointment_booking_settings.py b/erpnext/crm/doctype/appointment_booking_settings/test_appointment_booking_settings.py index f4cab812daa..721eae7676d 100644 --- a/erpnext/crm/doctype/appointment_booking_settings/test_appointment_booking_settings.py +++ b/erpnext/crm/doctype/appointment_booking_settings/test_appointment_booking_settings.py @@ -4,13 +4,16 @@ import datetime import frappe +from frappe.utils import add_to_date, getdate +from erpnext.setup.doctype.holiday_list.test_holiday_list import make_holiday_list from erpnext.tests.utils import ERPNextTestSuite class TestAppointmentBookingSettings(ERPNextTestSuite): - """The settings validate each availability slot: from-time must precede to-time and - the slot length must be a whole multiple of the appointment duration.""" + def assert_invalid(self, settings): + with self.assertRaises(frappe.ValidationError): + settings.save() def make_settings(self, appointment_duration=30): doc = frappe.new_doc("Appointment Booking Settings") @@ -19,7 +22,30 @@ class TestAppointmentBookingSettings(ERPNextTestSuite): def dt(self, hms): # the controller parses times against a fixed epoch date - return datetime.datetime.strptime("01/01/1970 " + hms, "%d/%m/%Y %H:%M:%S") + return datetime.datetime.strptime("1970-01-01 " + hms, "%Y-%m-%d %H:%M:%S") + + def get_valid_scheduling_settings(self): + holiday_list = make_holiday_list( + "_Test Booking Settings Holiday List", + from_date=getdate(), + to_date=add_to_date(getdate(), days=30), + holiday_dates=[], + ) + + settings = frappe.get_doc("Appointment Booking Settings") + settings.enable_scheduling = 1 + settings.appointment_duration = 30 + settings.advance_booking_days = 7 + settings.verification_link_expiry_duration = 30 + settings.holiday_list = holiday_list.name + settings.set("agent_list", []) + settings.append("agent_list", {"user": "Administrator"}) + settings.set("availability_of_slots", []) + settings.append( + "availability_of_slots", + {"day_of_week": "Monday", "from_time": "09:00:00", "to_time": "17:00:00"}, + ) + return settings def test_from_time_must_precede_to_time(self): doc = self.make_settings() @@ -42,18 +68,58 @@ class TestAppointmentBookingSettings(ERPNextTestSuite): frappe.ValidationError, doc.duration_is_divisible, self.dt("09:00:00"), self.dt("09:45:00") ) - def test_validate_checks_every_slot(self): - bad = self.make_settings(appointment_duration=30) - bad.append( - "availability_of_slots", - {"day_of_week": "Monday", "from_time": "09:00:00", "to_time": "09:45:00"}, - ) - self.assertRaises(frappe.ValidationError, bad.validate) + def test_scheduling_requires_slots(self): + settings = self.get_valid_scheduling_settings() + settings.set("availability_of_slots", []) - # a clean 60-minute slot passes end to end - good = self.make_settings(appointment_duration=30) - good.append( + self.assert_invalid(settings) + + def test_validate_checks_every_slot(self): + settings = self.get_valid_scheduling_settings() + settings.append( "availability_of_slots", - {"day_of_week": "Monday", "from_time": "09:00:00", "to_time": "10:00:00"}, + {"day_of_week": "Tuesday", "from_time": "09:00:00", "to_time": "09:45:00"}, ) - good.validate() + + self.assert_invalid(settings) + + def test_scheduling_requires_holiday_list_covering_today(self): + settings = self.get_valid_scheduling_settings() + settings.holiday_list = None + self.assert_invalid(settings) + + expired_list = make_holiday_list( + "_Test Booking Settings Expired Holiday List", + from_date=add_to_date(getdate(), days=-60), + to_date=add_to_date(getdate(), days=-30), + holiday_dates=[], + ) + settings.holiday_list = expired_list.name + self.assert_invalid(settings) + + def test_scheduling_requires_advance_booking_days(self): + settings = self.get_valid_scheduling_settings() + settings.advance_booking_days = 0 + + self.assert_invalid(settings) + + def test_portal_requires_scheduling(self): + settings = frappe.get_doc("Appointment Booking Settings") + settings.enable_scheduling = 0 + settings.enable_appointment_portal = 1 + + self.assert_invalid(settings) + + def test_portal_expiry_duration_bounds(self): + settings = self.get_valid_scheduling_settings() + settings.enable_appointment_portal = 1 + settings.verification_link_expiry_duration = 5 + + self.assert_invalid(settings) + + def test_number_of_agents_derived_from_agent_list(self): + settings = self.get_valid_scheduling_settings() + settings.number_of_agents = 99 + settings.save() + + self.assertEqual(frappe.db.get_single_value("Appointment Booking Settings", "number_of_agents"), 1) diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py index d75adbc2f41..0652db3333a 100644 --- a/erpnext/crm/utils.py +++ b/erpnext/crm/utils.py @@ -189,6 +189,7 @@ def get_filtered_todos(ref_doctype, ref_docname, status: str | tuple[str, str]): "allocated_to", "date", ], + order_by="date asc", ) @@ -218,6 +219,7 @@ def get_filtered_events(ref_doctype, ref_docname, open: bool): & (event_link.reference_docname == ref_docname) & (event_status_filter) ) + .orderby(event.starts_on) ) data = query.run(as_dict=True) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index e783d9e0fc4..d19be15485c 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -17,6 +17,7 @@ add_to_apps_screen = [ "title": app_title, "route": app_home, "has_permission": "erpnext.check_app_permission", + "sequence_id": 1, } ] @@ -307,6 +308,18 @@ sounds = [ has_upload_permission = {"Employee": "erpnext.setup.doctype.employee.employee.has_upload_permission"} +permission_query_conditions = { + "Item": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions", + "Customer": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions", + "Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions", +} + +has_permission = { + "Item": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission", + "Customer": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission", + "Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission", +} + has_website_permission = { "Sales Order": "erpnext.controllers.website_list_for_contact.has_website_permission", "Quotation": "erpnext.controllers.website_list_for_contact.has_website_permission", @@ -356,6 +369,7 @@ doc_events = { "validate": [ "erpnext.support.doctype.service_level_agreement.service_level_agreement.apply", "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.check_for_running_deletion_job", + "erpnext.stock.doctype.company_restriction.company_restriction.validate_transaction_company", ], }, tuple(period_closing_doctypes): { @@ -364,6 +378,9 @@ doc_events = { tuple(pre_submit_validation_doctypes): { "validate": "erpnext.accounts.utils.pre_submit_validation", }, + ("Item", "Customer", "Supplier"): { + "validate": "erpnext.stock.doctype.company_restriction.company_restriction.validate_allowed_companies", + }, "Stock Entry": { "on_submit": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty", "on_cancel": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty", @@ -451,8 +468,6 @@ scheduler_events = { "cron": { "0/15 * * * *": [ "erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs", - ], - "0/30 * * * *": [ "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.run_parallel_reposting", ], # Hourly but offset by 30 minutes @@ -467,6 +482,7 @@ scheduler_events = { ], "hourly_long": [], "hourly_maintenance": [ + "erpnext.crm.doctype.appointment.appointment.handle_expired_unverified_appointments", "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries", "erpnext.utilities.bulk_transaction.retry", "erpnext.projects.doctype.project.project.collect_project_status", diff --git a/erpnext/locale/ar.po b/erpnext/locale/ar.po index c4a8da42bdf..0dd2ec9c4ad 100644 --- a/erpnext/locale/ar.po +++ b/erpnext/locale/ar.po @@ -2,8 +2,8 @@ msgid "" msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: hello@frappe.io\n" -"POT-Creation-Date: 2026-07-05 10:19+0000\n" -"PO-Revision-Date: 2026-07-05 21:31\n" +"POT-Creation-Date: 2026-07-19 10:04+0000\n" +"PO-Revision-Date: 2026-07-19 13:55\n" "Last-Translator: hello@frappe.io\n" "Language-Team: Arabic\n" "MIME-Version: 1.0\n" @@ -86,15 +86,15 @@ msgstr " التجميع الفرعي" msgid " Summary" msgstr "" -#: erpnext/stock/doctype/item/item.py:281 +#: erpnext/stock/doctype/item/item.py:286 msgid "\"Customer Provided Item\" cannot be Purchase Item also" msgstr "\"الأصناف المقدمة من العملاء\" لا يمكن شرائها" -#: erpnext/stock/doctype/item/item.py:283 +#: erpnext/stock/doctype/item/item.py:288 msgid "\"Customer Provided Item\" cannot have Valuation Rate" msgstr "\"الأصناف المقدمة من العملاء\" لا يمكن ان تحتوي على تكلفة" -#: erpnext/stock/doctype/item/item.py:385 +#: erpnext/stock/doctype/item/item.py:390 msgid "\"Is Fixed Asset\" cannot be unchecked, as Asset record exists against the item" msgstr "\"اصل ثابت\" لا يمكن أن يكون غير محدد، حيث يوجد سجل أصول مقابل البند" @@ -154,7 +154,7 @@ msgstr "" msgid "% Delivered" msgstr "% تسليم" -#: erpnext/manufacturing/doctype/bom/bom.js:1022 +#: erpnext/manufacturing/doctype/bom/bom.js:1026 #, python-format msgid "% Finished Item Quantity" msgstr "% كمية المنتج النهائي" @@ -259,7 +259,7 @@ msgstr "" msgid "% of materials delivered against this Sales Order" msgstr "" -#: erpnext/controllers/accounts_controller.py:1298 +#: erpnext/controllers/accounts_controller.py:1225 msgid "'Account' in the Accounting section of Customer {0}" msgstr "" @@ -267,7 +267,7 @@ msgstr "" msgid "'Allow Multiple Sales Orders Against a Customer's Purchase Order'" msgstr "" -#: erpnext/controllers/trends.py:62 +#: erpnext/controllers/trends.py:66 msgid "'Based On' and 'Group By' can not be the same" msgstr "" @@ -275,7 +275,7 @@ msgstr "" msgid "'Days Since Last Order' must be greater than or equal to zero" msgstr "يجب أن تكون \"الأيام منذ آخر طلب\" أكبر من أو تساوي الصفر" -#: erpnext/controllers/accounts_controller.py:1303 +#: erpnext/controllers/accounts_controller.py:1230 msgid "'Default {0} Account' in Company {1}" msgstr "" @@ -284,7 +284,7 @@ msgid "'Entries' cannot be empty" msgstr "المدخلات لا يمكن أن تكون فارغة" #: erpnext/stock/report/batch_item_expiry_status/batch_item_expiry_status.py:24 -#: erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py:127 +#: erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py:132 #: erpnext/stock/report/stock_analytics/stock_analytics.py:322 msgid "'From Date' is required" msgstr "من تاريخ (مطلوب)" @@ -293,7 +293,7 @@ msgstr "من تاريخ (مطلوب)" msgid "'From Date' must be after 'To Date'" msgstr "\"من تاريخ \" يجب أن يكون بعد \" إلى تاريخ \"" -#: erpnext/stock/doctype/item/item.py:468 +#: erpnext/stock/doctype/item/item.py:473 msgid "'Has Serial No' cannot be 'Yes' for non-stock item" msgstr "" @@ -312,7 +312,7 @@ msgid "'Opening'" msgstr "'افتتاحي'" #: erpnext/stock/report/batch_item_expiry_status/batch_item_expiry_status.py:27 -#: erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py:129 +#: erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py:134 #: erpnext/stock/report/stock_analytics/stock_analytics.py:328 msgid "'To Date' is required" msgstr "' إلى تاريخ ' مطلوب" @@ -337,8 +337,8 @@ msgstr "{0} الحساب مستخدم بواسطة{1} استخدم حساب آخ msgid "'{0}' has been already added." msgstr "لقد تمت إضافة '{0}' بالفعل." -#: erpnext/setup/doctype/company/company.py:376 -#: erpnext/setup/doctype/company/company.py:387 +#: erpnext/setup/doctype/company/company.py:378 +#: erpnext/setup/doctype/company/company.py:389 msgid "'{0}' should be in company currency {1}." msgstr "" @@ -477,11 +477,11 @@ msgstr "" msgid "1 Loyalty Points = How much base currency?" msgstr "1 نقاط الولاء = كم العملة الأساسية؟" -#: erpnext/public/js/templates/shop_floor_template.html:992 +#: erpnext/public/js/templates/shop_floor_template.html:1012 msgid "1 completed job card" msgstr "" -#: erpnext/public/js/templates/shop_floor_template.html:860 +#: erpnext/public/js/templates/shop_floor_template.html:880 msgid "1 draft job card awaiting submission" msgstr "" @@ -494,15 +494,15 @@ msgstr "1 ساعة" msgid "1 invoice" msgstr "" -#: erpnext/public/js/templates/shop_floor_template.html:901 +#: erpnext/public/js/templates/shop_floor_template.html:921 msgid "1 job card awaiting Manufacture entry" msgstr "" -#: erpnext/public/js/templates/shop_floor_template.html:942 +#: erpnext/public/js/templates/shop_floor_template.html:962 msgid "1 pending job card" msgstr "" -#: erpnext/public/js/templates/shop_floor_template.html:1030 +#: erpnext/public/js/templates/shop_floor_template.html:1050 msgid "1 submitted today" msgstr "" @@ -623,8 +623,8 @@ msgstr "" msgid "90 Above" msgstr "أكثر من 90" -#: erpnext/accounts/report/accounts_receivable/accounts_receivable.py:1292 -#: erpnext/accounts/report/accounts_receivable/accounts_receivable.py:1293 +#: erpnext/accounts/report/accounts_receivable/accounts_receivable.py:1294 +#: erpnext/accounts/report/accounts_receivable/accounts_receivable.py:1295 msgid "<0" msgstr "<0" @@ -836,7 +836,7 @@ msgstr "" msgid "

Posting Date {0} cannot be before Purchase Order date for the following: