diff --git a/erpnext/accounts/doctype/account/account.json b/erpnext/accounts/doctype/account/account.json index 8b74dd823ec..a5f8d60cf4b 100644 --- a/erpnext/accounts/doctype/account/account.json +++ b/erpnext/accounts/doctype/account/account.json @@ -203,7 +203,7 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2025-08-02 06:26:44.657146", + "modified": "2026-04-14 18:14:42.202065", "modified_by": "Administrator", "module": "Accounts", "name": "Account", @@ -256,6 +256,14 @@ "role": "Accounts Manager", "share": 1, "write": 1 + }, + { + "role": "HR User", + "select": 1 + }, + { + "role": "HR Manager", + "select": 1 } ], "row_format": "Dynamic", diff --git a/erpnext/accounts/doctype/account/test_account.py b/erpnext/accounts/doctype/account/test_account.py index 95f17967fa7..f840ac86207 100644 --- a/erpnext/accounts/doctype/account/test_account.py +++ b/erpnext/accounts/doctype/account/test_account.py @@ -321,72 +321,6 @@ class TestAccount(ERPNextTestSuite): self.assertEqual(balance, 0) -def _make_test_records(verbose=None): - from frappe.tests.utils import make_test_objects - - accounts = [ - # [account_name, parent_account, is_group] - ["_Test Bank", "Bank Accounts", 0, "Bank", None], - ["_Test Bank USD", "Bank Accounts", 0, "Bank", "USD"], - ["_Test Bank EUR", "Bank Accounts", 0, "Bank", "EUR"], - ["_Test Cash", "Cash In Hand", 0, "Cash", None], - ["_Test Account Stock Expenses", "Direct Expenses", 1, None, None], - ["_Test Account Shipping Charges", "_Test Account Stock Expenses", 0, "Chargeable", None], - ["_Test Account Customs Duty", "_Test Account Stock Expenses", 0, "Tax", None], - ["_Test Account Insurance Charges", "_Test Account Stock Expenses", 0, "Chargeable", None], - ["_Test Account Stock Adjustment", "_Test Account Stock Expenses", 0, "Stock Adjustment", None], - ["_Test Employee Advance", "Current Liabilities", 0, None, None], - ["_Test Account Tax Assets", "Current Assets", 1, None, None], - ["_Test Account VAT", "_Test Account Tax Assets", 0, "Tax", None], - ["_Test Account Service Tax", "_Test Account Tax Assets", 0, "Tax", None], - ["_Test Account Reserves and Surplus", "Current Liabilities", 0, None, None], - ["_Test Account Cost for Goods Sold", "Expenses", 0, None, None], - ["_Test Account Excise Duty", "_Test Account Tax Assets", 0, "Tax", None], - ["_Test Account Education Cess", "_Test Account Tax Assets", 0, "Tax", None], - ["_Test Account S&H Education Cess", "_Test Account Tax Assets", 0, "Tax", None], - ["_Test Account CST", "Direct Expenses", 0, "Tax", None], - ["_Test Account Discount", "Direct Expenses", 0, None, None], - ["_Test Write Off", "Indirect Expenses", 0, None, None], - ["_Test Exchange Gain/Loss", "Indirect Expenses", 0, None, None], - ["_Test Account Sales", "Direct Income", 0, None, None], - # related to Account Inventory Integration - ["_Test Account Stock In Hand", "Current Assets", 0, None, None], - # fixed asset depreciation - ["_Test Fixed Asset", "Current Assets", 0, "Fixed Asset", None], - ["_Test Accumulated Depreciations", "Current Assets", 0, "Accumulated Depreciation", None], - ["_Test Depreciations", "Expenses", 0, "Depreciation", None], - ["_Test Gain/Loss on Asset Disposal", "Expenses", 0, None, None], - # Receivable / Payable Account - ["_Test Receivable", "Current Assets", 0, "Receivable", None], - ["_Test Payable", "Current Liabilities", 0, "Payable", None], - ["_Test Receivable USD", "Current Assets", 0, "Receivable", "USD"], - ["_Test Payable USD", "Current Liabilities", 0, "Payable", "USD"], - ] - - for company, abbr in [ - ["_Test Company", "_TC"], - ["_Test Company 1", "_TC1"], - ["_Test Company with perpetual inventory", "TCP1"], - ]: - test_objects = make_test_objects( - "Account", - [ - { - "doctype": "Account", - "account_name": account_name, - "parent_account": parent_account + " - " + abbr, - "company": company, - "is_group": is_group, - "account_type": account_type, - "account_currency": currency, - } - for account_name, parent_account, is_group, account_type, currency in accounts - ], - ) - - return test_objects - - def get_inventory_account(company, warehouse=None): account = None if warehouse: diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py index 70253c674c5..549449cce42 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py @@ -82,7 +82,7 @@ class AccountingDimension(Document): else: frappe.throw(_("Company {0} is added more than once").format(frappe.bold(default.company))) - def after_insert(self): + def on_update(self): if frappe.in_test: make_dimension_in_accounting_doctypes(doc=self) else: diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.js b/erpnext/accounts/doctype/accounts_settings/accounts_settings.js index 931e05a716b..2fda643640b 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.js +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.js @@ -2,7 +2,15 @@ // For license information, please see license.txt frappe.ui.form.on("Accounts Settings", { - refresh: function (frm) {}, + refresh: function (frm) { + frm.set_query("document_type", "repost_allowed_types", function (doc, cdt, cdn) { + return { + filters: { + name: ["in", frappe.boot.sysdefaults.repost_allowed_doctypes], + }, + }; + }); + }, enable_immutable_ledger: function (frm) { if (!frm.doc.enable_immutable_ledger) { return; diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 29673e89b6c..0807f07d8d9 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -62,6 +62,8 @@ "reconciliation_queue_size", "column_break_resa", "exchange_gain_loss_posting_date", + "repost_section", + "repost_allowed_types", "payment_options_section", "enable_loyalty_point_program", "column_break_ctam", @@ -702,6 +704,17 @@ "fieldname": "fetch_payment_schedule_in_payment_request", "fieldtype": "Check", "label": "Fetch Payment Schedule In Payment Request" + }, + { + "fieldname": "repost_section", + "fieldtype": "Section Break", + "label": "Repost" + }, + { + "fieldname": "repost_allowed_types", + "fieldtype": "Table", + "label": "Allowed Doctypes", + "options": "Repost Allowed Types" } ], "grid_page_length": 50, @@ -711,7 +724,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-03-30 07:32:58.182018", + "modified": "2026-04-13 15:30:28.729627", "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 94b35eba00a..693d0918d20 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -10,6 +10,9 @@ from frappe.custom.doctype.property_setter.property_setter import make_property_ from frappe.model.document import Document from frappe.utils import cint +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_accounting_dimensions, +) from erpnext.accounts.utils import sync_auto_reconcile_config SELLING_DOCTYPES = [ @@ -44,6 +47,8 @@ class AccountsSettings(Document): if TYPE_CHECKING: from frappe.types import DF + from erpnext.accounts.doctype.repost_allowed_types.repost_allowed_types import RepostAllowedTypes + add_taxes_from_item_tax_template: DF.Check add_taxes_from_taxes_and_charges_template: DF.Check allow_multi_currency_invoices_against_single_party_account: DF.Check @@ -86,6 +91,7 @@ class AccountsSettings(Document): receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor", "Raw SQL"] receivable_payable_remarks_length: DF.Int reconciliation_queue_size: DF.Int + repost_allowed_types: DF.Table[RepostAllowedTypes] 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 @@ -140,6 +146,7 @@ class AccountsSettings(Document): frappe.clear_cache() self.validate_and_sync_auto_reconcile_config() + self.update_property_for_accounting_dimension() def validate_stale_days(self): if not self.allow_stale and cint(self.stale_days) <= 0: @@ -186,6 +193,17 @@ class AccountsSettings(Document): title=_("Auto Tax Settings Error"), ) + def update_property_for_accounting_dimension(self): + doctypes = [entry.document_type for entry in self.repost_allowed_types] + if not doctypes: + return + + from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import get_child_docs + + doctypes += get_child_docs(doctypes) + + set_allow_on_submit_for_dimension_fields(doctypes) + @frappe.whitelist() def drop_ar_sql_procedures(self): from erpnext.accounts.report.accounts_receivable.accounts_receivable import InitSQLProceduresForAR @@ -225,3 +243,12 @@ def create_property_setter_for_hiding_field(doctype, field_name, hide): "Check", validate_fields_for_doctype=False, ) + + +def set_allow_on_submit_for_dimension_fields(doctypes): + for dt in doctypes: + meta = frappe.get_meta(dt) + for dimension in get_accounting_dimensions(): + df = meta.get_field(dimension) + if df and not df.allow_on_submit: + frappe.db.set_value("Custom Field", dt + "-" + dimension, "allow_on_submit", 1) diff --git a/erpnext/accounts/doctype/cost_center/cost_center.json b/erpnext/accounts/doctype/cost_center/cost_center.json index 8a84e2c4c01..bb69300ff8c 100644 --- a/erpnext/accounts/doctype/cost_center/cost_center.json +++ b/erpnext/accounts/doctype/cost_center/cost_center.json @@ -126,7 +126,7 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2025-01-22 10:46:42.904001", + "modified": "2026-04-14 18:15:27.367298", "modified_by": "Administrator", "module": "Accounts", "name": "Cost Center", @@ -173,11 +173,20 @@ "role": "Employee", "select": 1, "share": 1 + }, + { + "role": "HR User", + "select": 1 + }, + { + "role": "HR Manager", + "select": 1 } ], + "row_format": "Dynamic", "search_fields": "parent_cost_center, is_group", "show_name_in_global_search": 1, "sort_field": "creation", "sort_order": "ASC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/item_tax_template/item_tax_template.js b/erpnext/accounts/doctype/item_tax_template/item_tax_template.js index b608ccd3568..94c87fcae93 100644 --- a/erpnext/accounts/doctype/item_tax_template/item_tax_template.js +++ b/erpnext/accounts/doctype/item_tax_template/item_tax_template.js @@ -47,3 +47,12 @@ frappe.ui.form.on("Item Tax Template", { }); }, }); + +frappe.ui.form.on("Item Tax Template Detail", { + not_applicable: function (frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.not_applicable) { + frappe.model.set_value(cdt, cdn, "tax_rate", 0); + } + }, +}); diff --git a/erpnext/accounts/doctype/item_tax_template/item_tax_template.py b/erpnext/accounts/doctype/item_tax_template/item_tax_template.py index 464fb0f8227..8a23331b3f6 100644 --- a/erpnext/accounts/doctype/item_tax_template/item_tax_template.py +++ b/erpnext/accounts/doctype/item_tax_template/item_tax_template.py @@ -27,8 +27,15 @@ class ItemTaxTemplate(Document): # end: auto-generated types def validate(self): + self.set_zero_rate_for_not_applicable_tax() self.validate_tax_accounts() + def set_zero_rate_for_not_applicable_tax(self): + """Ensure tax_rate is 0 for any row marked as not applicable.""" + for row in self.get("taxes"): + if row.not_applicable: + row.tax_rate = 0 + def autoname(self): if self.company and self.title: abbr = frappe.get_cached_value("Company", self.company, "abbr") diff --git a/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json b/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json index 5092489c012..d11d249894d 100644 --- a/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json +++ b/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json @@ -6,7 +6,8 @@ "engine": "InnoDB", "field_order": [ "tax_type", - "tax_rate" + "tax_rate", + "not_applicable" ], "fields": [ { @@ -21,20 +22,30 @@ "fieldname": "tax_rate", "fieldtype": "Float", "in_list_view": 1, - "label": "Tax Rate" + "label": "Tax Rate", + "read_only_depends_on": "eval:doc.not_applicable" + }, + { + "default": "0", + "description": "Check if this tax is not applicable to items (distinct from 0% rate)", + "fieldname": "not_applicable", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Not Applicable" } ], "istable": 1, "links": [], - "modified": "2024-03-27 13:09:55.735360", + "modified": "2025-12-26 17:19:18.791891", "modified_by": "Administrator", "module": "Accounts", "name": "Item Tax Template Detail", "owner": "Administrator", "permissions": [], "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/accounts/doctype/item_tax_template_detail/item_tax_template_detail.py b/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.py index 810235e3691..a98fbc6ba86 100644 --- a/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.py +++ b/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.py @@ -14,6 +14,7 @@ class ItemTaxTemplateDetail(Document): if TYPE_CHECKING: from frappe.types import DF + not_applicable: DF.Check parent: DF.Data parentfield: DF.Data parenttype: DF.Data diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index cc3b616d601..1e3ad530f72 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -648,7 +648,7 @@ $.extend(erpnext.journal_entry, { reqd: 1, default: frm.doc.posting_date, }, - { fieldtype: "Small Text", fieldname: "user_remark", label: __("User Remark") }, + { fieldtype: "Small Text", fieldname: "remark", label: __("Remark") }, { fieldtype: "Select", fieldname: "naming_series", @@ -665,8 +665,14 @@ $.extend(erpnext.journal_entry, { var values = dialog.get_values(); frm.set_value("posting_date", values.posting_date); - frm.set_value("user_remark", values.user_remark); frm.set_value("naming_series", values.naming_series); + if (values.remark) { + frm.set_value("custom_remark", 1); + frm.set_value("remark", values.remark); + } else { + frm.set_value("custom_remark", 0); + frm.set_value("remark", ""); + } // clear table is used because there might've been an error while adding child // and cleanup didn't happen diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json index 0856f41bb09..0eaefb5f618 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.json +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json @@ -78,6 +78,7 @@ "from_template", "title", "column_break3", + "custom_remark", "remark", "mode_of_payment", "party_not_required" @@ -202,6 +203,7 @@ { "fieldname": "user_remark", "fieldtype": "Small Text", + "hidden": 1, "label": "User Remark", "no_copy": 1, "oldfieldname": "user_remark", @@ -315,7 +317,7 @@ "no_copy": 1, "oldfieldname": "remark", "oldfieldtype": "Small Text", - "read_only": 1 + "read_only_depends_on": "eval: !doc.custom_remark" }, { "depends_on": "eval:doc.voucher_type== \"Inter Company Journal Entry\"", @@ -651,6 +653,17 @@ "fieldname": "tax_withholding_tab", "fieldtype": "Tab Break", "label": "Tax Withholding" + }, + { + "fieldname": "auto_repeat_section", + "fieldtype": "Section Break", + "label": "Auto Repeat" + }, + { + "default": "0", + "fieldname": "custom_remark", + "fieldtype": "Check", + "label": "Custom Remark" } ], "icon": "fa fa-file-text", @@ -665,7 +678,7 @@ "table_fieldname": "payment_entries" } ], - "modified": "2026-03-09 17:15:26.569327", + "modified": "2026-04-08 14:19:30.870894", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry", diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 8b6cd3eee23..7a30139796a 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -61,6 +61,7 @@ class JournalEntry(AccountsController): cheque_no: DF.Data | None clearance_date: DF.Date | None company: DF.Link + custom_remark: DF.Check difference: DF.Currency due_date: DF.Date | None finance_book: DF.Link | None @@ -1026,8 +1027,8 @@ class JournalEntry(AccountsController): if self.flags.skip_remarks_creation: return - if self.user_remark: - r.append(_("Note: {0}").format(self.user_remark)) + if self.get("custom_remark"): + return if self.cheque_no: if self.cheque_date: @@ -1548,7 +1549,7 @@ def get_against_jv(doctype, txt, searchfield, start, page_len, filters): frappe.qb.from_(JournalEntry) .join(JournalEntryAccount) .on(JournalEntryAccount.parent == JournalEntry.name) - .select(JournalEntry.name, JournalEntry.posting_date, JournalEntry.user_remark) + .select(JournalEntry.name, JournalEntry.posting_date, JournalEntry.remark) .where(JournalEntryAccount.account == filters.get("account")) .where(JournalEntryAccount.reference_type.isnull() | (JournalEntryAccount.reference_type == "")) .where(JournalEntry.docstatus == 1) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js index 4ef7b998b8a..6ea0df946f2 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js @@ -1,5 +1,5 @@ frappe.listview_settings["Journal Entry"] = { - add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark"], + add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark"], get_indicator: function (doc) { if (doc.docstatus === 1) { return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`]; diff --git a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py index 53d6013e1e2..581a0866721 100644 --- a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py @@ -413,9 +413,9 @@ class TestJournalEntry(ERPNextTestSuite): from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center # Configure Repost Accounting Ledger for JVs - settings = frappe.get_doc("Repost Accounting Ledger Settings") - if not [x for x in settings.allowed_types if x.document_type == "Journal Entry"]: - settings.append("allowed_types", {"document_type": "Journal Entry", "allowed": True}) + settings = frappe.get_doc("Accounts Settings") + if "Journal Entry" not in [x.document_type for x in settings.repost_allowed_types]: + settings.append("repost_allowed_types", {"document_type": "Journal Entry"}) settings.save() # Create JV with defaut cost center - _Test Cost Center @@ -523,7 +523,7 @@ class TestJournalEntry(ERPNextTestSuite): jv = frappe.new_doc("Journal Entry") jv.posting_date = nowdate() jv.company = "_Test Company" - jv.user_remark = "test" + jv.remark = "test" jv.extend( "accounts", [ @@ -592,6 +592,14 @@ class TestJournalEntry(ERPNextTestSuite): self.assertEqual(jv.pay_to_recd_from, "_Test Receiver 2") + def test_custom_remark(self): + # When custom_remark is enabled, remark should not be auto-overwritten on save + jv = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, save=False) + jv.custom_remark = 1 + jv.remark = "My custom remark text" + jv.insert() + self.assertEqual(jv.remark, "My custom remark text") + def test_credit_limit_for_customer(self): customer = make_customer("_Test New Customer") set_credit_limit("_Test New Customer", "_Test Company", 50) @@ -620,7 +628,7 @@ def make_journal_entry( jv = frappe.new_doc("Journal Entry") jv.posting_date = posting_date or nowdate() jv.company = company or "_Test Company" - jv.user_remark = "test" + jv.remark = "test" jv.multi_currency = 1 jv.set( "accounts", diff --git a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json index f7e7fc414bd..ec03452e56a 100644 --- a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json +++ b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json @@ -48,7 +48,7 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2024-08-16 19:22:42.942264", + "modified": "2026-04-14 18:16:47.795986", "modified_by": "Administrator", "module": "Accounts", "name": "Mode of Payment", @@ -68,12 +68,21 @@ "read": 1, "report": 1, "role": "Accounts User" + }, + { + "role": "HR User", + "select": 1 + }, + { + "role": "HR Manager", + "select": 1 } ], "quick_entry": 1, + "row_format": "Dynamic", "show_name_in_global_search": 1, "sort_field": "creation", "sort_order": "ASC", "states": [], "translated_doctype": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 9d2890f5e79..f6a23ddf450 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -2105,6 +2105,37 @@ class TestPaymentEntry(ERPNextTestSuite): self.assertEqual(ref.voucher_no, so.name) self.assertIsNotNone(ref.payment_term) + def test_project_name_in_exchange_gain_loss_entry(self): + si = create_sales_invoice( + customer="_Test Customer USD", + debit_to="_Test Receivable USD - _TC", + currency="USD", + conversion_rate=50, + do_not_submit=True, + ) + from erpnext.projects.doctype.project.test_project import make_project + + si.project = make_project({"project_name": "_Test Project for Exchange Gain Loss Entry"}).name + + si.submit() + + pe = get_payment_entry("Sales Invoice", si.name) + + pe.source_exchange_rate = 100 + + pe.insert() + pe.submit() + + rows = frappe.get_all( + "Journal Entry Account", + or_filters=[{"reference_name": pe.name}, {"reference_name": si.name}], + fields=["project"], + ) + self.assertEqual(len(rows), 2) + + self.assertEqual(rows[0].project, si.project) + self.assertEqual(rows[1].project, si.project) + def create_payment_entry(**args): payment_entry = frappe.new_doc("Payment 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 9fefe251533..ad9f8751942 100644 --- a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json +++ b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json @@ -811,6 +811,7 @@ }, { "default": "0", + "fetch_from": "item_code.grant_commission", "fieldname": "grant_commission", "fieldtype": "Check", "label": "Grant Commission", @@ -857,7 +858,7 @@ ], "istable": 1, "links": [], - "modified": "2025-11-12 18:11:11.818015", + "modified": "2026-04-20 16:16:12.322024", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice Item", diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index 56ba6a040dc..6df17e4e2cd 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -662,7 +662,7 @@ def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None): if pricing_rule.is_recursive: transaction_qty = sum( [ - row.qty + flt(row.qty) for row in doc.items if not row.is_free_item and row.item_code == args.item_code diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index b42574ee206..5cf3c6be879 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -2256,9 +2256,9 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin): def test_repost_accounting_entries(self): # update repost settings - settings = frappe.get_doc("Repost Accounting Ledger Settings") - if not [x for x in settings.allowed_types if x.document_type == "Purchase Invoice"]: - settings.append("allowed_types", {"document_type": "Purchase Invoice", "allowed": True}) + settings = frappe.get_doc("Accounts Settings") + if "Purchase Invoice" not in [x.document_type for x in settings.repost_allowed_types]: + settings.append("repost_allowed_types", {"document_type": "Purchase Invoice"}) settings.save() pi = make_purchase_invoice( diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py index 819ced1c911..807a1789881 100644 --- a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py @@ -219,7 +219,6 @@ def get_allowed_types_from_settings(child_doc: bool = False): x.document_type for x in frappe.db.get_all( "Repost Allowed Types", - filters={"allowed": True}, fields=["document_type"], distinct=True, ) @@ -274,14 +273,13 @@ def validate_docs_for_voucher_types(doc_voucher_types): if disallowed_types := voucher_types.difference(allowed_types): message = "are" if len(disallowed_types) > 1 else "is" frappe.throw( - _("{0} {1} not allowed to be reposted. Modify {2} to enable reposting.").format( + _( + "{0} {1} not allowed to be reposted. You can enable it by adding it '{2}' table in {3}." + ).format( frappe.bold(comma_and(list(disallowed_types))), message, - frappe.bold( - frappe.utils.get_link_to_form( - "Repost Accounting Ledger Settings", "Repost Accounting Ledger Settings" - ) - ), + frappe.bold("Allowed Doctype"), + frappe.utils.get_link_to_form("Accounts Settings"), ) ) @@ -289,8 +287,6 @@ def validate_docs_for_voucher_types(doc_voucher_types): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_repost_allowed_types(doctype, txt, searchfield, start, page_len, filters): - filters = {"allowed": True} - if txt: filters.update({"document_type": ("like", f"%{txt}%")}) diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/test_repost_accounting_ledger.py b/erpnext/accounts/doctype/repost_accounting_ledger/test_repost_accounting_ledger.py index 793bde5c99f..935047e2e35 100644 --- a/erpnext/accounts/doctype/repost_accounting_ledger/test_repost_accounting_ledger.py +++ b/erpnext/accounts/doctype/repost_accounting_ledger/test_repost_accounting_ledger.py @@ -203,6 +203,11 @@ class TestRepostAccountingLedger(ERPNextTestSuite): def test_06_repost_purchase_receipt(self): from erpnext.accounts.doctype.account.test_account import create_account + if not frappe.db.set_value("Company", "_Test Company", "service_expense_account"): + frappe.db.set_value( + "Company", "_Test Company", "service_expense_account", "Marketing Expenses - _TC" + ) + provisional_account = create_account( account_name="Provision Account", parent_account="Current Liabilities - _TC", @@ -275,7 +280,8 @@ def update_repost_settings(): "Journal Entry", "Purchase Receipt", ] - repost_settings = frappe.get_doc("Repost Accounting Ledger Settings") - for x in allowed_types: - repost_settings.append("allowed_types", {"document_type": x, "allowed": True}) - repost_settings.save() + settings = frappe.get_doc("Accounts Settings") + for _type in allowed_types: + if _type not in [x.document_type for x in settings.repost_allowed_types]: + settings.append("repost_allowed_types", {"document_type": _type}) + settings.save() diff --git a/erpnext/accounts/doctype/repost_accounting_ledger_settings/__init__.py b/erpnext/accounts/doctype/repost_accounting_ledger_settings/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/erpnext/accounts/doctype/repost_accounting_ledger_settings/repost_accounting_ledger_settings.js b/erpnext/accounts/doctype/repost_accounting_ledger_settings/repost_accounting_ledger_settings.js deleted file mode 100644 index 8c83ca50431..00000000000 --- a/erpnext/accounts/doctype/repost_accounting_ledger_settings/repost_accounting_ledger_settings.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -// frappe.ui.form.on("Repost Accounting Ledger Settings", { -// refresh(frm) { - -// }, -// }); diff --git a/erpnext/accounts/doctype/repost_accounting_ledger_settings/repost_accounting_ledger_settings.json b/erpnext/accounts/doctype/repost_accounting_ledger_settings/repost_accounting_ledger_settings.json deleted file mode 100644 index 808986bba23..00000000000 --- a/erpnext/accounts/doctype/repost_accounting_ledger_settings/repost_accounting_ledger_settings.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "actions": [], - "creation": "2023-11-07 09:57:20.619939", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "allowed_types" - ], - "fields": [ - { - "fieldname": "allowed_types", - "fieldtype": "Table", - "label": "Allowed Doctypes", - "options": "Repost Allowed Types" - } - ], - "grid_page_length": 50, - "hide_toolbar": 0, - "in_create": 1, - "issingle": 1, - "links": [], - "modified": "2026-03-16 13:28:21.312607", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Repost Accounting Ledger Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "Administrator", - "select": 1, - "share": 1, - "write": 1 - }, - { - "create": 1, - "delete": 1, - "read": 1, - "role": "System Manager", - "select": 1, - "write": 1 - } - ], - "row_format": "Dynamic", - "sort_field": "creation", - "sort_order": "DESC", - "states": [], - "track_changes": 1 -} diff --git a/erpnext/accounts/doctype/repost_accounting_ledger_settings/repost_accounting_ledger_settings.py b/erpnext/accounts/doctype/repost_accounting_ledger_settings/repost_accounting_ledger_settings.py deleted file mode 100644 index d6ef25cd1b7..00000000000 --- a/erpnext/accounts/doctype/repost_accounting_ledger_settings/repost_accounting_ledger_settings.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -import frappe -from frappe.model.document import Document - -from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( - get_accounting_dimensions, -) -from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import get_child_docs - - -class RepostAccountingLedgerSettings(Document): - # begin: auto-generated types - # This code is auto-generated. Do not modify anything in this block. - - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - from frappe.types import DF - - from erpnext.accounts.doctype.repost_allowed_types.repost_allowed_types import RepostAllowedTypes - - allowed_types: DF.Table[RepostAllowedTypes] - # end: auto-generated types - - def validate(self): - self.update_property_for_accounting_dimension() - - def update_property_for_accounting_dimension(self): - doctypes = [entry.document_type for entry in self.allowed_types if entry.allowed] - if not doctypes: - return - doctypes += get_child_docs(doctypes) - - set_allow_on_submit_for_dimension_fields(doctypes) - - -def set_allow_on_submit_for_dimension_fields(doctypes): - for dt in doctypes: - meta = frappe.get_meta(dt) - for dimension in get_accounting_dimensions(): - df = meta.get_field(dimension) - if df and not df.allow_on_submit: - frappe.db.set_value("Custom Field", dt + "-" + dimension, "allow_on_submit", 1) diff --git a/erpnext/accounts/doctype/repost_accounting_ledger_settings/test_repost_accounting_ledger_settings.py b/erpnext/accounts/doctype/repost_accounting_ledger_settings/test_repost_accounting_ledger_settings.py deleted file mode 100644 index 6a8698e96af..00000000000 --- a/erpnext/accounts/doctype/repost_accounting_ledger_settings/test_repost_accounting_ledger_settings.py +++ /dev/null @@ -1,11 +0,0 @@ -# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -# import frappe - - -from erpnext.tests.utils import ERPNextTestSuite - - -class TestRepostAccountingLedgerSettings(ERPNextTestSuite): - pass diff --git a/erpnext/accounts/doctype/repost_allowed_types/repost_allowed_types.json b/erpnext/accounts/doctype/repost_allowed_types/repost_allowed_types.json index cb7e6bac5f8..e9206b3cf2e 100644 --- a/erpnext/accounts/doctype/repost_allowed_types/repost_allowed_types.json +++ b/erpnext/accounts/doctype/repost_allowed_types/repost_allowed_types.json @@ -6,9 +6,7 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "document_type", - "column_break_sfzb", - "allowed" + "document_type" ], "fields": [ { @@ -17,29 +15,20 @@ "in_list_view": 1, "label": "Doctype", "options": "DocType" - }, - { - "default": "0", - "fieldname": "allowed", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Allowed" - }, - { - "fieldname": "column_break_sfzb", - "fieldtype": "Column Break" } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:32.415806", + "modified": "2026-04-14 16:53:16.806714", "modified_by": "Administrator", "module": "Accounts", "name": "Repost Allowed Types", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/repost_allowed_types/repost_allowed_types.py b/erpnext/accounts/doctype/repost_allowed_types/repost_allowed_types.py index 8226a910171..7f0064cc3cf 100644 --- a/erpnext/accounts/doctype/repost_allowed_types/repost_allowed_types.py +++ b/erpnext/accounts/doctype/repost_allowed_types/repost_allowed_types.py @@ -14,7 +14,6 @@ class RepostAllowedTypes(Document): if TYPE_CHECKING: from frappe.types import DF - allowed: DF.Check document_type: DF.Link | None parent: DF.Data parentfield: DF.Data diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.js b/erpnext/accounts/doctype/shipping_rule/shipping_rule.js index 1ece3e6c3dd..5c02fd2f127 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.js +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.js @@ -25,6 +25,10 @@ frappe.ui.form.on("Shipping Rule", { }, calculate_based_on: function (frm) { frm.trigger("toggle_reqd"); + if (frm.doc.calculate_based_on === "Fixed") { + frm.clear_table("conditions"); + frm.refresh_field("conditions"); + } }, toggle_reqd: function (frm) { frm.toggle_reqd("shipping_amount", frm.doc.calculate_based_on === "Fixed"); diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py index 3ec11364afa..7b226560668 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py @@ -58,6 +58,11 @@ class ShippingRule(Document): self.validate_overlapping_shipping_rule_conditions() def validate_from_to_values(self): + if self.calculate_based_on == "Fixed": + if self.conditions: + self.set("conditions", []) + return + zero_to_values = [] for d in self.get("conditions"): diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js index 4d74a5c2d7c..c94f8c245ec 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.js +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js @@ -34,6 +34,17 @@ frappe.query_reports["Accounts Payable"] = { }, options: "Cost Center", }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "MultiSelectList", + options: "Project", + get_data: function (txt) { + return frappe.db.get_link_options("Project", txt, { + company: frappe.query_report.get_filter_value("company"), + }); + }, + }, { fieldname: "party_account", label: __("Payable Account"), diff --git a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py index a8074468f55..ee296447c0e 100644 --- a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py +++ b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py @@ -117,3 +117,49 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin): self.assertEqual(len(report[1]), 2) self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term]) + + def test_project_filter(self): + project = frappe.get_doc( + {"doctype": "Project", "project_name": "_Test AP Project", "company": self.company} + ).insert() + + pi = self.create_purchase_invoice(do_not_submit=True) + pi.project = project.name + pi.save().submit() + + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + "project": [project.name], + } + + report = execute(filters)[1] + self.assertEqual(len(report), 1) + row = report[0] + self.assertEqual(row.project, project.name) + self.assertEqual(row.invoiced, 300.0) + + def test_project_on_report_output(self): + """ + Report row must carry the invoice's project. + """ + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + } + + project = frappe.get_doc( + {"doctype": "Project", "project_name": "_Test AP Project Output", "company": self.company} + ).insert() + + pi = self.create_purchase_invoice(do_not_submit=True) + pi.project = project.name + pi.save().submit() + + report = execute(filters) + + self.assertEqual(len(report[1]), 1) + row = report[1][0] + self.assertEqual([pi.name, project.name, 300], [row.voucher_no, row.project, row.outstanding]) 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 a4cb0584bf1..3f603b62833 100644 --- a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js +++ b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js @@ -53,6 +53,17 @@ frappe.query_reports["Accounts Payable Summary"] = { }, options: "Cost Center", }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "MultiSelectList", + options: "Project", + get_data: function (txt) { + return frappe.db.get_link_options("Project", txt, { + company: frappe.query_report.get_filter_value("company"), + }); + }, + }, { fieldname: "party_type", label: __("Party Type"), diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js index a05b17bfade..e7aa1f57036 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js @@ -36,6 +36,17 @@ frappe.query_reports["Accounts Receivable"] = { }, options: "Cost Center", }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "MultiSelectList", + options: "Project", + get_data: function (txt) { + return frappe.db.get_link_options("Project", txt, { + company: frappe.query_report.get_filter_value("company"), + }); + }, + }, { fieldname: "party_type", label: __("Party Type"), diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index e29ed79cc49..4ca1bdcfffb 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -196,6 +196,7 @@ class ReceivablePayableReport: and ple.against_voucher_type in self.advance_payment_doctypes ): self.voucher_balance[key].cost_center = ple.cost_center + self.voucher_balance[key].project = ple.project self.get_invoices(ple) @@ -362,6 +363,7 @@ class ReceivablePayableReport: posting_date, account_currency, cost_center, + project, sum(invoiced) `invoiced`, sum(paid) `paid`, sum(credit_note) `credit_note`, @@ -390,6 +392,7 @@ class ReceivablePayableReport: "credit_note_in_account_currency", "outstanding_in_account_currency", "cost_center", + "project", ]: _d[field] = x.get(field) @@ -931,6 +934,7 @@ class ReceivablePayableReport: ple.against_voucher_no, ple.party_type, ple.cost_center, + ple.project, ple.party, ple.posting_date, ple.due_date, @@ -998,6 +1002,9 @@ class ReceivablePayableReport: if self.filters.cost_center: self.get_cost_center_conditions() + if self.filters.project: + self.qb_selection_filter.append(self.ple.project.isin(self.filters.project)) + self.add_accounting_dimensions_filters() def get_cost_center_conditions(self): @@ -1237,6 +1244,7 @@ class ReceivablePayableReport: ) self.add_column(label=_("Cost Center"), fieldname="cost_center", fieldtype="Data") + self.add_column(label=_("Project"), fieldname="project", fieldtype="Link", options="Project") self.add_column(label=_("Voucher Type"), fieldname="voucher_type", fieldtype="Data") self.add_column( label=_("Voucher No"), @@ -1413,6 +1421,7 @@ class InitSQLProceduresForAR: posting_date date, account_currency {_varchar_type}, cost_center {_varchar_type}, + project {_varchar_type}, invoiced {_currency_type}, paid {_currency_type}, credit_note {_currency_type}, @@ -1432,6 +1441,7 @@ class InitSQLProceduresForAR: against_voucher_no {_varchar_type}, party_type {_varchar_type}, cost_center {_varchar_type}, + project {_varchar_type}, party {_varchar_type}, posting_date date, due_date date, @@ -1447,7 +1457,7 @@ class InitSQLProceduresForAR: begin if not exists (select name from `{_voucher_balance_name}` where name = sha1(concat_ws(',', ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party))) then - insert into `{_voucher_balance_name}` values (sha1(concat_ws(',', ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party)), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, 0, 0, 0, 0, 0, 0); + insert into `{_voucher_balance_name}` values (sha1(concat_ws(',', ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party)), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, ple.project, 0, 0, 0, 0, 0, 0); end if; end; """ @@ -1489,7 +1499,7 @@ class InitSQLProceduresForAR: end if; - insert into `{_voucher_balance_name}` values (sha1(concat_ws(',', ple.account, ple.voucher_type, ple.voucher_no, ple.party)), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0); + insert into `{_voucher_balance_name}` values (sha1(concat_ws(',', ple.account, ple.voucher_type, ple.voucher_no, ple.party)), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', '', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0); end; """ diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 4b852e0583d..9b8b8b709db 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -774,22 +774,18 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin): def test_party_account_filter(self): si1 = self.create_sales_invoice() - self.customer2 = ( - frappe.get_doc( - { - "doctype": "Customer", - "customer_name": "Jane Doe", - "type": "Individual", - "default_currency": "USD", - } - ) - .insert() - .submit() - ) + jane = frappe.get_doc( + { + "doctype": "Customer", + "customer_name": "Jane Doe", + "type": "Individual", + "default_currency": "USD", + } + ).insert() + self.customer = jane.name si2 = self.create_sales_invoice(do_not_submit=True) si2.posting_date = add_days(today(), -1) - si2.customer = self.customer2.name si2.currency = "USD" si2.conversion_rate = 80 si2.debit_to = self.debtors_usd @@ -997,22 +993,18 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin): self.assertEqual(expected_data, report_output) def test_future_payments_on_foreign_currency(self): - self.customer2 = ( - frappe.get_doc( - { - "doctype": "Customer", - "customer_name": "Jane Doe", - "type": "Individual", - "default_currency": "USD", - } - ) - .insert() - .submit() - ) + jane = frappe.get_doc( + { + "doctype": "Customer", + "customer_name": "Jane Doe", + "type": "Individual", + "default_currency": "USD", + } + ).insert() + self.customer = jane.name si = self.create_sales_invoice(do_not_submit=True) si.posting_date = add_days(today(), -1) - si.customer = self.customer2.name si.currency = "USD" si.conversion_rate = 80 si.debit_to = self.debtors_usd @@ -1204,3 +1196,52 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin): self.assertEqual(len(report[1]), 2) self.assertEqual([si.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term]) + + def test_project_filter(self): + project = frappe.get_doc( + {"doctype": "Project", "project_name": "_Test AR Project", "company": self.company} + ).insert() + + si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True) + si.project = project.name + si.save().submit() + + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + "project": [project.name], + } + + report = execute(filters)[1] + self.assertEqual(len(report), 1) + row = report[0] + self.assertEqual(row.project, project.name) + self.assertEqual(row.invoiced, 100.0) + + def test_project_on_report_output(self): + """ + Report row must carry the invoice's project even when the payment entry + has no project set. + """ + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + } + + project = frappe.get_doc( + {"doctype": "Project", "project_name": "_Test AR Project Output", "company": self.company} + ).insert() + + si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True) + si.project = project.name + si.save().submit() + + # payment has no project — report row must still show the invoice's project + self.create_payment_entry(si.name) + report = execute(filters) + + self.assertEqual(len(report[1]), 1) + row = report[1][0] + self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding]) 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 c8e59d6e054..46585071174 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js @@ -53,6 +53,17 @@ frappe.query_reports["Accounts Receivable Summary"] = { }, options: "Cost Center", }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "MultiSelectList", + options: "Project", + get_data: function (txt) { + return frappe.db.get_link_options("Project", txt, { + company: frappe.query_report.get_filter_value("company"), + }); + }, + }, { fieldname: "party_type", label: __("Party Type"), diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 518b49f0ea5..dfba16a77eb 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -774,7 +774,28 @@ class GrossProfitGenerator: # IMP NOTE # stock_ledger_entries should already be filtered by item_code and warehouse and # sorted by posting_date desc, posting_time desc - if item_code in self.non_stock_items and (row.project or row.cost_center): + if ( + row.delivered_by_supplier + and row.so_detail + and ( + po_details := frappe.get_all( + "Purchase Order Item", + filters={"sales_order_item": row.so_detail, "docstatus": 1}, + pluck="name", + ) + ) + ): + from frappe.query_builder.functions import Sum + + table = frappe.qb.DocType("Purchase Invoice Item") + query = ( + frappe.qb.from_(table) + .select(Sum(table.qty * table.base_net_rate)) + .where((table.po_detail.isin(po_details)) & (table.docstatus == 1)) + ) + return flt(query.run()[0][0]) + + elif item_code in self.non_stock_items and (row.project or row.cost_center): # Issue 6089-Get last purchasing rate for non-stock item item_rate = self.get_last_purchase_rate(item_code, row) return flt(row.qty) * item_rate @@ -804,26 +825,6 @@ class GrossProfitGenerator: return self.calculate_buying_amount_from_sle( row, my_sle, parenttype, parent, item_row, item_code ) - elif ( - row.delivered_by_supplier - and row.so_detail - and ( - po_details := frappe.get_all( - "Purchase Order Item", - filters={"sales_order_item": row.so_detail, "docstatus": 1}, - pluck="name", - ) - ) - ): - from frappe.query_builder.functions import Sum - - table = frappe.qb.DocType("Purchase Invoice Item") - query = ( - frappe.qb.from_(table) - .select(Sum(table.stock_qty * table.base_net_rate)) - .where((table.po_detail.isin(po_details)) & (table.docstatus == 1)) - ) - return flt(query.run()[0][0]) elif row.sales_order and row.so_detail: incoming_amount = self.get_buying_amount_from_so_dn(row.sales_order, row.so_detail, item_code) if incoming_amount: diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py index 2ec8931b63d..4801bf15b56 100644 --- a/erpnext/accounts/report/purchase_register/purchase_register.py +++ b/erpnext/accounts/report/purchase_register/purchase_register.py @@ -499,7 +499,7 @@ def get_invoice_tax_map(invoice_list, invoice_expense_map, expense_accounts, inc else sum(base_tax_amount_after_discount_amount) * -1 end as tax_amount from `tabPurchase Taxes and Charges` where parent in (%s) and category in ('Total', 'Valuation and Total') - and base_tax_amount_after_discount_amount != 0 + and base_tax_amount_after_discount_amount != 0 and parenttype='Purchase Invoice' group by parent, account_head, add_deduct_tax """ % ", ".join(["%s"] * len(invoice_list)), diff --git a/erpnext/accounts/report/purchase_register/test_purchase_register.py b/erpnext/accounts/report/purchase_register/test_purchase_register.py index 400ee899fa1..e4ce5ffcfe3 100644 --- a/erpnext/accounts/report/purchase_register/test_purchase_register.py +++ b/erpnext/accounts/report/purchase_register/test_purchase_register.py @@ -5,6 +5,7 @@ import frappe from frappe.utils import add_months, today from erpnext.accounts.report.purchase_register.purchase_register import execute +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.tests.utils import ERPNextTestSuite @@ -26,6 +27,52 @@ class TestPurchaseRegister(ERPNextTestSuite): self.assertEqual(first_row.total_tax, 100) self.assertEqual(first_row.grand_total, 1100) + def test_purchase_register_ignores_tax_rows_from_other_doctype(self): + frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'") + frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'") + + filters = frappe._dict(company="_Test Company 6", from_date=add_months(today(), -1), to_date=today()) + + pi = make_purchase_invoice() + + # Real workflow setup: create a Purchase Receipt tax row in the same shared child table. + pr = make_purchase_receipt( + company="_Test Company 6", + supplier="_Test Supplier", + item="_Test Item", + warehouse="_Test Warehouse - _TC6", + cost_center="_Test Cost Center - _TC6", + do_not_save=1, + do_not_submit=1, + qty=1, + rate=1000, + ) + pr.append( + "taxes", + { + "account_head": "GST - _TC6", + "cost_center": "_Test Cost Center - _TC6", + "add_deduct_tax": "Add", + "category": "Valuation and Total", + "charge_type": "Actual", + "description": "PR Tax", + "tax_amount": 100.0, + "rate": 100, + }, + ) + pr.insert() + pr.submit() + + # Mimic custom naming collision across doctypes (same parent value in shared child table). + frappe.rename_doc("Purchase Receipt", pr.name, pi.name, force=True) + + report_results = execute(filters) + first_row = frappe._dict(report_results[1][0]) + + self.assertEqual(first_row.voucher_no, pi.name) + self.assertEqual(first_row.total_tax, 100) + self.assertEqual(first_row.grand_total, 1100) + def test_purchase_register_ledger_view(self): frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'") frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'") diff --git a/erpnext/accounts/report/sales_register/test_sales_register.py b/erpnext/accounts/report/sales_register/test_sales_register.py index c6926d57dea..0875c7c143f 100644 --- a/erpnext/accounts/report/sales_register/test_sales_register.py +++ b/erpnext/accounts/report/sales_register/test_sales_register.py @@ -4,6 +4,7 @@ from frappe.utils import getdate, today from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.report.sales_register.sales_register import execute from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.tests.utils import ERPNextTestSuite @@ -72,6 +73,43 @@ class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin): report_output = {k: v for k, v in report[1][0].items() if k in expected_result} self.assertDictEqual(report_output, expected_result) + def test_sales_register_ignores_tax_rows_from_other_doctype(self): + si = self.create_sales_invoice(rate=98) + + # Real workflow setup: create a Sales Order with taxes in the shared child table. + so = make_sales_order( + item=self.item, + company=self.company, + customer=self.customer, + rate=77, + do_not_save=1, + do_not_submit=1, + ) + so.append( + "taxes", + { + "charge_type": "Actual", + "account_head": self.income_account, + "description": "SO Tax", + "tax_amount": 55.0, + }, + ) + so.insert() + so.submit() + + # Mimic custom naming collision across doctypes (same parent value in shared child table). + frappe.rename_doc("Sales Order", so.name, si.name, force=True) + + filters = frappe._dict({"from_date": today(), "to_date": today(), "company": self.company}) + report = execute(filters) + + self.assertEqual(len(report[1]), 1) + result = frappe._dict(report[1][0]) + self.assertEqual(result.voucher_no, si.name) + self.assertEqual(result.net_total, 98.0) + self.assertEqual(result.tax_total, 0) + self.assertEqual(result.grand_total, 98.0) + def test_journal_with_cost_center_filter(self): je1 = frappe.get_doc( { diff --git a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py index ed4a223311b..70813aaeb1d 100644 --- a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py @@ -68,8 +68,10 @@ def get_tax_withholding_data(filters): } data.append(row) - # Sort by section code and transaction date - data.sort(key=lambda x: (x["section_code"] or "", x["transaction_date"] or "")) + # Sort by section code, transaction date, then withholding_name for deterministic ordering + data.sort( + key=lambda x: (x["section_code"] or "", x["transaction_date"] or "", x["withholding_name"] or "") + ) return data diff --git a/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py index 49e50b7ff32..91683867188 100644 --- a/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py @@ -5,10 +5,12 @@ import frappe from frappe.utils import add_to_date, today from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry -from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice -from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.tax_withholding_category.test_tax_withholding_category import ( + create_purchase_invoice, + create_records, + create_sales_invoice, create_tax_withholding_category, + make_journal_entry_with_tax_withholding, ) from erpnext.accounts.report.tax_withholding_details.tax_withholding_details import execute from erpnext.accounts.test.accounts_mixin import AccountsTestMixin @@ -20,43 +22,45 @@ class TestTaxWithholdingDetails(ERPNextTestSuite, AccountsTestMixin): def setUp(self): self.create_company() self.clear_old_entries() - create_tax_accounts() + create_records() def test_tax_withholding_for_customers(self): create_tax_category(cumulative_threshold=300) - frappe.db.set_value("Customer", "_Test Customer", "tax_withholding_category", "TCS") - si = create_sales_invoice(rate=1000) - pe = create_tcs_payment_entry() + frappe.db.set_value("Customer", "Test TCS Customer", "tax_withholding_category", "TCS") + si = create_sales_invoice(customer="Test TCS Customer", rate=1000) + si.submit() + + create_tcs_payment_entry() jv = create_tcs_journal_entry() filters = frappe._dict( company="_Test Company", party_type="Customer", from_date=today(), to_date=today() ) result = execute(filters)[1] + expected_values = [ - # Check for JV totals using back calculation logic - [jv.name, "TCS", 0.075, -10000.0, -7.5, -10000.0], - [pe.name, "TCS", 0.075, 2550, 0.53, 2550.53], - [si.name, "TCS", 0.075, 1000, 0.52, 1000.52], + [jv.name, "TCS", 0.075, 1000.75, 0.75, 1000.75], + ["", "TCS", 0.075, 0, 0.75, 0], + [si.name, "TCS", 0.075, 1000.0, 0.75, 1000.75], ] self.check_expected_values(result, expected_values) def test_single_account_for_multiple_categories(self): - create_tax_category("TDS - 1", rate=10, account="TDS - _TC") - inv_1 = make_purchase_invoice(rate=1000, do_not_submit=True) - inv_1.tax_withholding_category = "TDS - 1" + create_tax_category("TDS - 1", rate=10, account="TDS - _TC", cumulative_threshold=1) + frappe.db.set_value("Supplier", "Test TDS Supplier", "tax_withholding_category", "TDS - 1") + inv_1 = create_purchase_invoice(supplier="Test TDS Supplier", rate=5000) inv_1.submit() - create_tax_category("TDS - 2", rate=20, account="TDS - _TC") - inv_2 = make_purchase_invoice(rate=1000, do_not_submit=True) - inv_2.tax_withholding_category = "TDS - 2" + create_tax_category("TDS - 2", rate=20, account="TDS - _TC", cumulative_threshold=1) + frappe.db.set_value("Supplier", "Test TDS Supplier", "tax_withholding_category", "TDS - 2") + inv_2 = create_purchase_invoice(supplier="Test TDS Supplier", rate=5000) inv_2.submit() result = execute( frappe._dict(company="_Test Company", party_type="Supplier", from_date=today(), to_date=today()) )[1] expected_values = [ - [inv_1.name, "TDS - 1", 10, 5000, 500, 5500], - [inv_2.name, "TDS - 2", 20, 5000, 1000, 6000], + [inv_1.name, "TDS - 1", 10, 5000, 500, 4500], + [inv_2.name, "TDS - 2", 20, 5000, 1000, 4000], ] self.check_expected_values(result, expected_values) @@ -81,20 +85,21 @@ class TestTaxWithholdingDetails(ERPNextTestSuite, AccountsTestMixin): tds_doc.save() - inv_1 = make_purchase_invoice( - rate=1000, posting_date=add_to_date(fiscal_year[1], days=1), do_not_save=True, do_not_submit=True + frappe.db.set_value("Supplier", "Test TDS Supplier", "tax_withholding_category", tds_doc.name) + inv_1 = create_purchase_invoice( + supplier="Test TDS Supplier", + rate=5000, + posting_date=add_to_date(fiscal_year[1], days=1), + set_posting_time=True, ) - inv_1.set_posting_time = 1 - inv_1.apply_tds = 1 - inv_1.tax_withholding_category = tds_doc.name - inv_1.save() inv_1.submit() - inv_2 = make_purchase_invoice(rate=1000, posting_date=from_date, do_not_save=True, do_not_submit=True) - inv_2.set_posting_time = 1 - inv_2.apply_tds = 1 - inv_2.tax_withholding_category = tds_doc.name - inv_2.save() + inv_2 = create_purchase_invoice( + supplier="Test TDS Supplier", + rate=5000, + posting_date=from_date, + set_posting_time=True, + ) inv_2.submit() result = execute( @@ -113,6 +118,7 @@ class TestTaxWithholdingDetails(ERPNextTestSuite, AccountsTestMixin): self.check_expected_values(result, expected_values) def check_expected_values(self, result, expected_values): + self.assertEqual(len(result), len(expected_values)) for i in range(len(result)): voucher = frappe._dict(result[i]) voucher_expected_values = expected_values[i] @@ -127,21 +133,6 @@ class TestTaxWithholdingDetails(ERPNextTestSuite, AccountsTestMixin): self.assertSequenceEqual(voucher_actual_values, voucher_expected_values) -def create_tax_accounts(): - account_names = ["TCS", "TDS"] - for account in account_names: - frappe.get_doc( - { - "doctype": "Account", - "company": "_Test Company", - "account_name": account, - "parent_account": "Duties and Taxes - _TC", - "report_type": "Balance Sheet", - "root_type": "Liability", - } - ).insert(ignore_if_duplicate=True) - - def create_tax_category(category="TCS", rate=0.075, account="TCS - _TC", cumulative_threshold=0): fiscal_year = get_fiscal_year(today(), company="_Test Company") from_date = fiscal_year[1] @@ -157,55 +148,34 @@ def create_tax_category(category="TCS", rate=0.075, account="TCS - _TC", cumulat ) -def create_tcs_payment_entry(): +def create_tcs_payment_entry(party="Test TCS Customer", category="TCS", amount=1000): + """Create a TCS Payment Entry that generates a Tax Withholding Entry (Over Withheld).""" payment_entry = create_payment_entry( payment_type="Receive", party_type="Customer", - party="_Test Customer", + party=party, paid_from="Debtors - _TC", paid_to="Cash - _TC", - paid_amount=2550, - ) - - payment_entry.append( - "taxes", - { - "account_head": "TCS - _TC", - "charge_type": "Actual", - "tax_amount": 0.53, - "add_deduct_tax": "Add", - "description": "Test", - "cost_center": "Main - _TC", - }, + paid_amount=amount, ) + payment_entry.apply_tds = 1 + payment_entry.tax_withholding_category = category + payment_entry.save() payment_entry.submit() return payment_entry -def create_tcs_journal_entry(): - jv = frappe.new_doc("Journal Entry") - jv.posting_date = today() - jv.company = "_Test Company" - jv.set( - "accounts", - [ - { - "account": "Debtors - _TC", - "party_type": "Customer", - "party": "_Test Customer", - "credit_in_account_currency": 10000, - }, - { - "account": "Debtors - _TC", - "party_type": "Customer", - "party": "_Test Customer", - "debit_in_account_currency": 9992.5, - }, - { - "account": "TCS - _TC", - "debit_in_account_currency": 7.5, - }, - ], +def create_tcs_journal_entry(party="Test TCS Customer", category="TCS", amount=1000): + """Create a TCS Credit Note Journal Entry that generates a Tax Withholding Entry.""" + jv = make_journal_entry_with_tax_withholding( + party_type="Customer", + party=party, + voucher_type="Credit Note", + amount=amount, + save=False, ) - jv.insert() - return jv.submit() + jv.apply_tds = 1 + jv.tax_withholding_category = category + jv.save() + jv.submit() + return jv diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 0c23353d1eb..fe68c4018aa 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -2513,6 +2513,7 @@ def create_gain_loss_journal( ref2_detail_no, cost_center, dimensions, + project=None, ) -> str: journal_entry = frappe.new_doc("Journal Entry") journal_entry.voucher_type = "Exchange Gain Or Loss" @@ -2539,6 +2540,7 @@ def create_gain_loss_journal( "account_currency": party_account_currency, "exchange_rate": 0, "cost_center": cost_center or erpnext.get_default_cost_center(company), + "project": project, "reference_type": ref1_dt, "reference_name": ref1_dn, "reference_detail_no": ref1_detail_no, @@ -2556,6 +2558,7 @@ def create_gain_loss_journal( "account_currency": gain_loss_account_currency, "exchange_rate": 1, "cost_center": cost_center or erpnext.get_default_cost_center(company), + "project": project, "reference_type": ref2_dt, "reference_name": ref2_dn, "reference_detail_no": ref2_detail_no, diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index 5a0edbdf5d1..3963bbb4959 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -24,6 +24,7 @@ "allow_zero_qty_in_supplier_quotation", "use_transaction_date_exchange_rate", "allow_zero_qty_in_request_for_quotation", + "allow_negative_rates_for_items", "column_break_12", "maintain_same_rate", "allow_multiple_items", @@ -279,6 +280,12 @@ "fieldname": "validate_consumed_qty", "fieldtype": "Check", "label": "Validate Consumed Qty (as per BOM)" + }, + { + "default": "0", + "fieldname": "allow_negative_rates_for_items", + "fieldtype": "Check", + "label": "Allow Negative rates for Items" } ], "grid_page_length": 50, @@ -288,7 +295,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-03-16 13:28:19.432589", + "modified": "2026-04-15 16:07:35.484787", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.py b/erpnext/buying/doctype/buying_settings/buying_settings.py index 3634f8a9069..8f358bb364b 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.py +++ b/erpnext/buying/doctype/buying_settings/buying_settings.py @@ -18,6 +18,7 @@ class BuyingSettings(Document): from frappe.types import DF allow_multiple_items: DF.Check + allow_negative_rates_for_items: DF.Check allow_zero_qty_in_purchase_order: DF.Check allow_zero_qty_in_request_for_quotation: DF.Check allow_zero_qty_in_supplier_quotation: DF.Check diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 09d7d30ab39..03254c30f6e 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -477,6 +477,11 @@ def create_supplier_quotation(doc): if isinstance(doc, str): doc = json.loads(doc) + if frappe.session.user not in frappe.get_all( + "Portal User", {"parent": doc.get("supplier")}, pluck="user" + ): + frappe.throw(_("Not Permitted"), frappe.PermissionError) + try: sq_doc = frappe.get_doc( { diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py index 9201f6c4f2d..36468a83dac 100644 --- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py @@ -264,6 +264,13 @@ def make_request_for_quotation(**args) -> "RequestforQuotation": for data in supplier_data: rfq.append("suppliers", data) + frappe.new_doc( + "Portal User", + user="Administrator", + parent=data.get("supplier"), + parentfield="portal_users", + parenttype="Supplier", + ).insert() rfq.append( "items", diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index e0bb6e85bab..6a2794dd9ab 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -68,6 +68,7 @@ from erpnext.setup.utils import get_exchange_rate from erpnext.stock.doctype.item.item import get_uom_conv_factor from erpnext.stock.doctype.packed_item.packed_item import make_packing_list from erpnext.stock.get_item_details import ( + NOT_APPLICABLE_TAX, ItemDetailsCtx, _get_item_tax_template, get_conversion_factor, @@ -1767,6 +1768,7 @@ class AccountsController(TransactionBase): arg.get("referenced_row"), arg.get("cost_center"), dimensions_dict, + arg.get("project"), ) frappe.msgprint( _("Exchange Gain/Loss amount has been booked through {0}").format( @@ -1851,6 +1853,7 @@ class AccountsController(TransactionBase): d.idx, self.cost_center, dimensions_dict, + self.project, ) frappe.msgprint( _("Exchange Gain/Loss amount has been booked through {0}").format( @@ -3684,8 +3687,11 @@ def add_taxes_from_tax_template(child_item, parent_doc, db_insert=True): if child_item.get("item_tax_rate") and add_taxes_from_item_tax_template: tax_map = json.loads(child_item.get("item_tax_rate")) - for tax_type in tax_map: - tax_rate = flt(tax_map[tax_type]) + for tax_type, tax_rate in tax_map.items(): + if tax_rate == NOT_APPLICABLE_TAX: + continue + + tax_rate = flt(tax_rate) taxes = parent_doc.get("taxes") or [] # add new row for tax head only if missing found = any(tax.account_head == tax_type for tax in taxes) diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index fd86291027e..5627dffea95 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -18,7 +18,11 @@ from erpnext.buying.utils import update_last_purchase_rate, validate_for_items from erpnext.controllers.accounts_controller import get_taxes_and_charges from erpnext.controllers.sales_and_purchase_return import get_rate_for_return from erpnext.controllers.subcontracting_controller import SubcontractingController -from erpnext.stock.get_item_details import get_conversion_factor, get_item_defaults +from erpnext.stock.get_item_details import ( + NOT_APPLICABLE_TAX, + get_conversion_factor, + get_item_defaults, +) from erpnext.stock.utils import get_incoming_rate @@ -523,6 +527,9 @@ class BuyingController(SubcontractingController): if account not in tax_accounts: continue + if rate == NOT_APPLICABLE_TAX: + continue + net_rate = item.base_net_amount if item.sales_incoming_rate: net_rate = item.qty * item.sales_incoming_rate diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 96ca67c28df..95c25c497bf 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -272,6 +272,12 @@ class StatusUpdater(Document): continue items_to_validate = [] + selling_negative_rate_allowed = frappe.get_single_value( + "Selling Settings", "allow_negative_rates_for_items" + ) + buying_negative_rate_allowed = frappe.get_single_value( + "Buying Settings", "allow_negative_rates_for_items" + ) # get unique transactions to update for d in self.get_all_children(): @@ -281,7 +287,12 @@ class StatusUpdater(Document): if hasattr(d, "qty") and d.qty > 0 and self.get("is_return"): frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code)) - if not frappe.get_single_value("Selling Settings", "allow_negative_rates_for_items"): + if ( + not selling_negative_rate_allowed and self.doctype in ["Sales Invoice", "Delivery Note"] + ) or ( + not buying_negative_rate_allowed + and self.doctype in ["Purchase Invoice", "Purchase Receipt"] + ): if hasattr(d, "item_code") and hasattr(d, "rate") and flt(d.rate) < 0: frappe.throw( _( @@ -289,7 +300,11 @@ class StatusUpdater(Document): ).format( frappe.bold(d.item_code), frappe.bold(_("`Allow Negative rates for Items`")), - get_link_to_form("Selling Settings", "Selling Settings"), + get_link_to_form( + "Selling Settings" + if self.doctype in ["Sales Invoice", "Delivery Note"] + else "Buying Settings" + ), ), ) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 05f2e18c878..5e883a6695c 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -142,14 +142,19 @@ class StockController(AccountsController): ]: for item in self.get("items"): if ( - (item.get("valuation_rate") == 0 or item.get("incoming_rate") == 0) + ( + item.get("valuation_rate") == 0 + or (item.get("incoming_rate") == 0 and self.get("update_stock", 1)) + ) and item.get("allow_zero_valuation_rate") == 0 and frappe.get_cached_value("Item", item.item_code, "is_stock_item") ): frappe.toast( - _( - "Row #{0}: Item {1} has zero rate but 'Allow Zero Valuation Rate' is not enabled." - ).format(item.idx, frappe.bold(item.item_code)), + _("Row #{0}: Item {1} has zero rate but '{2}' is not enabled.").format( + item.idx, + frappe.bold(item.item_code), + item.meta.get_label("allow_zero_valuation_rate"), + ), indicator="orange", ) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 21743e57f11..6a0a41b1799 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -20,7 +20,12 @@ from erpnext.controllers.accounts_controller import ( validate_taxes_and_charges, ) from erpnext.deprecation_dumpster import deprecated -from erpnext.stock.get_item_details import ItemDetailsCtx, _get_item_tax_template, get_item_tax_map +from erpnext.stock.get_item_details import ( + NOT_APPLICABLE_TAX, + ItemDetailsCtx, + _get_item_tax_template, + get_item_tax_map, +) from erpnext.utilities.regional import temporary_flag @@ -358,6 +363,9 @@ class calculate_taxes_and_totals: if cint(tax.included_in_print_rate): tax_rate = self._get_tax_rate(tax, item_tax_map) + if tax_rate == NOT_APPLICABLE_TAX: + return current_tax_fraction, inclusive_tax_amount_per_qty + if tax.charge_type == "On Net Total": current_tax_fraction = tax_rate / 100.0 @@ -382,9 +390,12 @@ class calculate_taxes_and_totals: def _get_tax_rate(self, tax, item_tax_map): if tax.account_head in item_tax_map: - return flt(item_tax_map.get(tax.account_head), self.doc.precision("rate", tax)) - else: - return tax.rate + rate = item_tax_map[tax.account_head] + if rate == NOT_APPLICABLE_TAX: + return NOT_APPLICABLE_TAX + return flt(rate, self.doc.precision("rate", tax)) + + return tax.rate def calculate_net_total(self): self.doc.total_qty = ( @@ -594,6 +605,9 @@ class calculate_taxes_and_totals: current_tax_amount = 0.0 current_net_amount = 0.0 + if tax_rate == NOT_APPLICABLE_TAX: + return current_net_amount, current_tax_amount + if tax.charge_type == "Actual": current_net_amount = item.net_amount # distribute the tax amount proportionally to each item row @@ -784,18 +798,17 @@ class calculate_taxes_and_totals: if self.doc.meta.get_field("rounded_total"): if self.doc.is_rounded_total_disabled(): self.doc.rounded_total = 0 - self.doc.base_rounded_total = 0 self.doc.rounding_adjustment = 0 - return - self.doc.rounded_total = round_based_on_smallest_currency_fraction( - self.doc.grand_total, self.doc.currency, self.doc.precision("rounded_total") - ) + else: + self.doc.rounded_total = round_based_on_smallest_currency_fraction( + self.doc.grand_total, self.doc.currency, self.doc.precision("rounded_total") + ) - # rounding adjustment should always be the difference vetween grand and rounded total - self.doc.rounding_adjustment = flt( - self.doc.rounded_total - self.doc.grand_total, self.doc.precision("rounding_adjustment") - ) + # rounding adjustment should always be the difference between grand and rounded total + self.doc.rounding_adjustment = flt( + self.doc.rounded_total - self.doc.grand_total, self.doc.precision("rounding_adjustment") + ) self._set_in_company_currency(self.doc, ["rounding_adjustment", "rounded_total"]) @@ -1290,7 +1303,8 @@ def get_itemised_tax(doc, with_tax_account=False): ) tax_info.tax_amount += flt(row.amount, precision) - tax_info.taxable_amount += flt(row.taxable_amount, precision) + conversion_rate = doc.conversion_rate or 1 + tax_info.taxable_amount += flt(row.taxable_amount / conversion_rate, precision) if with_tax_account: tax_info.tax_account = tax.account_head diff --git a/erpnext/controllers/tests/test_item_wise_tax_details.py b/erpnext/controllers/tests/test_item_wise_tax_details.py index 7e19c1dc057..81dd92c8606 100644 --- a/erpnext/controllers/tests/test_item_wise_tax_details.py +++ b/erpnext/controllers/tests/test_item_wise_tax_details.py @@ -301,3 +301,238 @@ class TestTaxesAndTotals(ERPNextTestSuite): tax = doc.taxes[0] detail = doc.item_wise_tax_details[0] self.assertEqual(detail.amount, tax.base_tax_amount_after_discount_amount) + + @change_settings("Selling Settings", {"allow_multiple_items": 1}) + def test_not_applicable_tax_in_item_tax_template(self): + """Test that items with 'not applicable' tax don't contribute to net amount of that tax.""" + template_7pct = frappe.get_doc( + { + "doctype": "Item Tax Template", + "title": "_Test VAT 7% Template", + "company": "_Test Company", + "taxes": [ + { + "tax_type": "_Test Account VAT - _TC", + "tax_rate": 7, + }, + { + "tax_type": "_Test Account Service Tax - _TC", + "tax_rate": 0, + "not_applicable": 1, + }, + ], + } + ).insert(ignore_if_duplicate=True) + + template_19pct = frappe.get_doc( + { + "doctype": "Item Tax Template", + "title": "_Test VAT 19% Template", + "company": "_Test Company", + "taxes": [ + { + "tax_type": "_Test Account VAT - _TC", + "tax_rate": 0, + }, + { + "tax_type": "_Test Account Service Tax - _TC", + "tax_rate": 19, + }, + ], + } + ).insert(ignore_if_duplicate=True) + + self.doc.items[0].item_tax_template = template_7pct.name + + self.doc.append( + "items", + { + "item_code": "_Test Item", + "qty": 1, + "rate": 100, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + "item_tax_template": template_19pct.name, + }, + ) + + self.doc.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT 7%", + "rate": 7, + }, + ) + + self.doc.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT 19%", + "rate": 19, + }, + ) + + self.doc.save() + + # VAT 7%: Both items contribute (Item 2 has 0% rate, not "not applicable") + self.assertEqual(self.doc.taxes[0].net_amount, 200.0) + # Service Tax 19%: Only Item 2 contributes (Item 1 has not_applicable) + self.assertEqual(self.doc.taxes[1].net_amount, 100.0) + + expected_values = [ + { + "item_row": self.doc.items[0].name, + "tax_row": self.doc.taxes[0].name, + "rate": 7.0, + "amount": 7.0, + "taxable_amount": 100.0, + }, + { + "item_row": self.doc.items[1].name, + "tax_row": self.doc.taxes[0].name, + "rate": 0.0, + "amount": 0.0, + "taxable_amount": 100.0, + }, + { + "item_row": self.doc.items[1].name, + "tax_row": self.doc.taxes[1].name, + "rate": 19.0, + "amount": 19.0, + "taxable_amount": 100.0, + }, + ] + + actual_values = [ + { + "item_row": row.item_row, + "tax_row": row.tax_row, + "rate": row.rate, + "amount": row.amount, + "taxable_amount": row.taxable_amount, + } + for row in self.doc.item_wise_tax_details + ] + + self.assertEqual(actual_values, expected_values) + + def test_not_applicable_tax_in_item_tax_template_with_different_items(self): + """Test that items with 'not applicable' tax don't contribute to net amount of that tax.""" + template_7pct = frappe.get_doc( + { + "doctype": "Item Tax Template", + "title": "_Test VAT 7% Template", + "company": "_Test Company", + "taxes": [ + { + "tax_type": "_Test Account VAT - _TC", + "tax_rate": 7, + }, + { + "tax_type": "_Test Account Service Tax - _TC", + "tax_rate": 0, + "not_applicable": 1, + }, + ], + } + ).insert(ignore_if_duplicate=True) + + template_19pct = frappe.get_doc( + { + "doctype": "Item Tax Template", + "title": "_Test VAT 19% Template", + "company": "_Test Company", + "taxes": [ + { + "tax_type": "_Test Account VAT - _TC", + "tax_rate": 0, + "not_applicable": 1, + }, + { + "tax_type": "_Test Account Service Tax - _TC", + "tax_rate": 19, + }, + ], + } + ).insert(ignore_if_duplicate=True) + + self.doc.items[0].item_tax_template = template_7pct.name + + self.doc.append( + "items", + { + "item_code": "_Test Item 2", + "qty": 1, + "rate": 100, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + "item_tax_template": template_19pct.name, + }, + ) + + self.doc.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT 7%", + "rate": 0, + }, + ) + + self.doc.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT 19%", + "rate": 0, + }, + ) + + self.doc.save() + + # VAT 7%: Only Item 1 contributes (Item 2 has not_applicable) + self.assertEqual(self.doc.taxes[0].net_amount, 100.0) + # Service Tax 19%: Only Item 2 contributes (Item 1 has not_applicable) + self.assertEqual(self.doc.taxes[1].net_amount, 100.0) + + expected_values = [ + { + "item_row": self.doc.items[0].name, + "tax_row": self.doc.taxes[0].name, + "rate": 7.0, + "amount": 7.0, + "taxable_amount": 100.0, + }, + { + "item_row": self.doc.items[1].name, + "tax_row": self.doc.taxes[1].name, + "rate": 19.0, + "amount": 19.0, + "taxable_amount": 100.0, + }, + ] + + actual_values = [ + { + "item_row": row.item_row, + "tax_row": row.tax_row, + "rate": row.rate, + "amount": row.amount, + "taxable_amount": row.taxable_amount, + } + for row in self.doc.item_wise_tax_details + ] + + self.assertEqual(actual_values, expected_values) diff --git a/erpnext/controllers/tests/test_taxes_and_totals.py b/erpnext/controllers/tests/test_taxes_and_totals.py new file mode 100644 index 00000000000..09d5231d7bc --- /dev/null +++ b/erpnext/controllers/tests/test_taxes_and_totals.py @@ -0,0 +1,37 @@ +import frappe + +from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.tests.utils import ERPNextTestSuite + + +class TestTaxesAndTotals(ERPNextTestSuite): + def test_disabling_rounded_total_resets_base_fields(self): + """Disabling rounded total should also clear base rounded values.""" + so = make_sales_order(do_not_save=True) + so.items[0].qty = 1 + so.items[0].rate = 1000.25 + so.items[0].price_list_rate = 1000.25 + so.items[0].discount_percentage = 0 + so.items[0].discount_amount = 0 + so.set("taxes", []) + + so.disable_rounded_total = 0 + calculate_taxes_and_totals(so) + + self.assertEqual(so.grand_total, 1000.25) + self.assertEqual(so.rounded_total, 1000.0) + self.assertEqual(so.rounding_adjustment, -0.25) + self.assertEqual(so.base_grand_total, 1000.25) + self.assertEqual(so.base_rounded_total, 1000.0) + self.assertEqual(so.base_rounding_adjustment, -0.25) + + # User toggles disable_rounded_total after values are already set. + so.disable_rounded_total = 1 + + calculate_taxes_and_totals(so) + + self.assertEqual(so.rounded_total, 0) + self.assertEqual(so.rounding_adjustment, 0) + self.assertEqual(so.base_rounded_total, 0) + self.assertEqual(so.base_rounding_adjustment, 0) diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index bc4e2b346d4..f8e152f5299 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -4,7 +4,9 @@ import frappe from frappe import _ -from frappe.utils import getdate +from frappe.utils import DateTimeLikeObject, getdate, today + +from erpnext.accounts.utils import get_fiscal_year def get_columns(filters, trans): @@ -45,6 +47,10 @@ def get_columns(filters, trans): def validate_filters(filters): + if not filters.get("fiscal_year"): + filters["fiscal_year"] = get_fiscal_year(today())[0] + if not filters.get("company"): + filters["company"] = frappe.defaults.get_user_default("Company") for f in ["Fiscal Year", "Based On", "Period", "Company"]: if not filters.get(f.lower().replace(" ", "_")): frappe.throw(_("{0} is mandatory").format(_(f))) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index cc4a08d8b67..2fd4be3a510 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -692,3 +692,10 @@ fields_for_group_similar_items = ["qty", "amount"] # ------------ # List of apps whose translatable strings should be excluded from this app's translations. ignore_translatable_strings_from = ["frappe"] +repost_allowed_doctypes = [ + "Sales Invoice", + "Purchase Invoice", + "Journal Entry", + "Payment Entry", + "Purchase Receipt", +] diff --git a/erpnext/locale/main.pot b/erpnext/locale/main.pot index 06178e5e76e..bacf12f631e 100644 --- a/erpnext/locale/main.pot +++ b/erpnext/locale/main.pot @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: ERPNext VERSION\n" "Report-Msgid-Bugs-To: hello@frappe.io\n" -"POT-Creation-Date: 2026-04-12 09:47+0000\n" -"PO-Revision-Date: 2026-04-12 09:47+0000\n" +"POT-Creation-Date: 2026-04-19 09:47+0000\n" +"PO-Revision-Date: 2026-04-19 09:47+0000\n" "Last-Translator: hello@frappe.io\n" "Language-Team: hello@frappe.io\n" "MIME-Version: 1.0\n" @@ -271,7 +271,7 @@ msgstr "" msgid "'Account' in the Accounting section of Customer {0}" msgstr "" -#: erpnext/selling/doctype/sales_order/sales_order.py:360 +#: erpnext/selling/doctype/sales_order/sales_order.py:361 msgid "'Allow Multiple Sales Orders Against a Customer's Purchase Order'" msgstr "" @@ -313,9 +313,9 @@ msgstr "" msgid "'Inspection Required before Purchase' has disabled for the item {0}, no need to create the QI" msgstr "" -#: erpnext/stock/report/stock_ledger/stock_ledger.py:665 -#: erpnext/stock/report/stock_ledger/stock_ledger.py:706 -#: erpnext/stock/report/stock_ledger/stock_ledger.py:811 +#: erpnext/stock/report/stock_ledger/stock_ledger.py:668 +#: erpnext/stock/report/stock_ledger/stock_ledger.py:709 +#: erpnext/stock/report/stock_ledger/stock_ledger.py:814 msgid "'Opening'" msgstr "" @@ -826,7 +826,7 @@ msgstr "" msgid "

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