From e37ceb5f69aa998f045964a536cd4c2d1ce800c8 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 3 Jul 2026 15:57:21 +0530 Subject: [PATCH 01/20] test: cover Stock Closing Entry duplicate date-range validation --- .../test_stock_closing_entry.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py b/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py index 3485e47030f..09670f0ff8c 100644 --- a/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py +++ b/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py @@ -1,6 +1,8 @@ # Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +from unittest.mock import patch + import frappe from frappe.utils import add_days, today @@ -57,3 +59,33 @@ class TestStockClosingEntry(ERPNextTestSuite): ).submit() self.last_closing_entry = entry.name return entry + + +class TestStockClosingEntryDuplicate(ERPNextTestSuite): + """validate_duplicate blocks a second submitted closing entry whose date range + overlaps an existing one for the same scope (company + warehouse/item filters).""" + + def make_closing(self, from_date, to_date, **fields): + doc = frappe.new_doc("Stock Closing Entry") + doc.company = COMPANY + doc.from_date = from_date + doc.to_date = to_date + doc.update(fields) + return doc + + def submit_closing(self, doc): + # the closing-balance build is enqueued on submit; skip it here + with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"): + doc.submit() + return doc + + def test_overlapping_range_is_rejected(self): + self.submit_closing(self.make_closing("2026-01-01", "2026-03-31")) + overlap = self.make_closing("2026-02-01", "2026-04-30") + self.assertRaises(frappe.ValidationError, overlap.insert) + + def test_non_overlapping_range_is_allowed(self): + self.submit_closing(self.make_closing("2026-01-01", "2026-03-31")) + later = self.make_closing("2026-04-01", "2026-06-30") + later.insert() + self.assertTrue(later.name) From ae0cfbd3e156a9ba087549e9fda65b9a757d7436 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 3 Jul 2026 17:06:51 +0530 Subject: [PATCH 02/20] test: assert the saved closing entry exists rather than a truthy name --- .../doctype/stock_closing_entry/test_stock_closing_entry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py b/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py index 09670f0ff8c..2f47551ff74 100644 --- a/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py +++ b/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py @@ -87,5 +87,5 @@ class TestStockClosingEntryDuplicate(ERPNextTestSuite): def test_non_overlapping_range_is_allowed(self): self.submit_closing(self.make_closing("2026-01-01", "2026-03-31")) later = self.make_closing("2026-04-01", "2026-06-30") - later.insert() - self.assertTrue(later.name) + later.insert() # would raise if validate_duplicate wrongly flagged it as overlapping + self.assertTrue(frappe.db.exists("Stock Closing Entry", later.name)) From 9901746e029c36c73689f777e0d34969a271ee83 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 3 Jul 2026 11:43:02 +0530 Subject: [PATCH 03/20] test: add coverage for Repost Payment Ledger --- .../test_repost_payment_ledger.py | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py b/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py index 8c2b8946121..3ba4ec15de7 100644 --- a/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py +++ b/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py @@ -1,11 +1,50 @@ -# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe - +import frappe +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.tests.utils import ERPNextTestSuite +COMPANY = "_Test Company" + class TestRepostPaymentLedger(ERPNextTestSuite): - pass + """Repost Payment Ledger auto-selects submitted vouchers on/after a cutoff date + (unless rows are added manually) and queues them for a ledger rebuild.""" + + def setUp(self): + frappe.set_user("Administrator") + + def make_repost(self, **args): + args = frappe._dict(args) + doc = frappe.new_doc("Repost Payment Ledger") + doc.company = COMPANY + doc.posting_date = args.get("posting_date", "2026-06-01") + doc.voucher_type = args.get("voucher_type", "Sales Invoice") + doc.add_manually = args.get("add_manually", 0) + return doc + + def test_loads_submitted_vouchers_on_or_after_cutoff(self): + in_range = create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1) + before_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-01-15", rate=100, qty=1) + + doc = self.make_repost(posting_date="2026-06-01", voucher_type="Sales Invoice") + doc.save() # before_validate loads the vouchers and sets status + + loaded = {v.voucher_no for v in doc.repost_vouchers} + self.assertIn(in_range.name, loaded) + self.assertNotIn(before_cutoff.name, loaded) + self.assertEqual(doc.repost_status, "Queued") + + def test_add_manually_preserves_user_rows(self): + # a Sales Invoice that WOULD match the filter, to prove manual mode ignores it + si = create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1) + + doc = self.make_repost(add_manually=1) + doc.append("repost_vouchers", {"voucher_type": "Sales Invoice", "voucher_no": si.name}) + doc.save() + + rows = [(v.voucher_type, v.voucher_no) for v in doc.repost_vouchers] + # the row is kept exactly as entered; no filter-based auto-loading happens + self.assertEqual(rows, [("Sales Invoice", si.name)]) From f68f53dec008727f7a17ad69692d0788d8848e3f Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 3 Jul 2026 12:09:10 +0530 Subject: [PATCH 04/20] test: cover on-cutoff boundary in voucher loading --- .../repost_payment_ledger/test_repost_payment_ledger.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py b/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py index 3ba4ec15de7..98c7929f09a 100644 --- a/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py +++ b/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py @@ -26,14 +26,17 @@ class TestRepostPaymentLedger(ERPNextTestSuite): return doc def test_loads_submitted_vouchers_on_or_after_cutoff(self): - in_range = create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1) + after_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1) + on_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-06-01", rate=100, qty=1) before_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-01-15", rate=100, qty=1) doc = self.make_repost(posting_date="2026-06-01", voucher_type="Sales Invoice") doc.save() # before_validate loads the vouchers and sets status loaded = {v.voucher_no for v in doc.repost_vouchers} - self.assertIn(in_range.name, loaded) + self.assertIn(after_cutoff.name, loaded) + # the filter is >= so an invoice posted exactly on the cutoff is included + self.assertIn(on_cutoff.name, loaded) self.assertNotIn(before_cutoff.name, loaded) self.assertEqual(doc.repost_status, "Queued") From 4c26ec8cd9a3e22c2d290d36c09063d4bdc88e68 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 3 Jul 2026 14:26:00 +0530 Subject: [PATCH 05/20] test: make add_manually test distinguish manual mode from auto-loading --- .../test_repost_payment_ledger.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py b/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py index 98c7929f09a..c0498adb8a4 100644 --- a/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py +++ b/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py @@ -41,13 +41,15 @@ class TestRepostPaymentLedger(ERPNextTestSuite): self.assertEqual(doc.repost_status, "Queued") def test_add_manually_preserves_user_rows(self): - # a Sales Invoice that WOULD match the filter, to prove manual mode ignores it - si = create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1) + # manually add a BEFORE-cutoff invoice (which the filter would never load) while a + # matching after-cutoff invoice also exists. If auto-loading wrongly ran it would + # drop the manual row and pull the after-cutoff one, so this distinguishes the modes. + manual_si = create_sales_invoice(company=COMPANY, posting_date="2026-01-15", rate=100, qty=1) + create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1) - doc = self.make_repost(add_manually=1) - doc.append("repost_vouchers", {"voucher_type": "Sales Invoice", "voucher_no": si.name}) + doc = self.make_repost(add_manually=1, posting_date="2026-06-01") + doc.append("repost_vouchers", {"voucher_type": "Sales Invoice", "voucher_no": manual_si.name}) doc.save() rows = [(v.voucher_type, v.voucher_no) for v in doc.repost_vouchers] - # the row is kept exactly as entered; no filter-based auto-loading happens - self.assertEqual(rows, [("Sales Invoice", si.name)]) + self.assertEqual(rows, [("Sales Invoice", manual_si.name)]) From 7f903b63dd8ca98035c8a63e18100304b42fcc3a Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 17:26:58 +0530 Subject: [PATCH 06/20] test: add coverage for CRM Settings sync and contact-us guards --- .../doctype/crm_settings/test_crm_settings.py | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/erpnext/crm/doctype/crm_settings/test_crm_settings.py b/erpnext/crm/doctype/crm_settings/test_crm_settings.py index 64a5addefcb..e89ed263140 100644 --- a/erpnext/crm/doctype/crm_settings/test_crm_settings.py +++ b/erpnext/crm/doctype/crm_settings/test_crm_settings.py @@ -1,9 +1,39 @@ -# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe + +import frappe from erpnext.tests.utils import ERPNextTestSuite class TestCRMSettings(ERPNextTestSuite): - pass + """CRM Settings guards its Frappe-CRM sync and Contact-Us opportunity toggles.""" + + def make_settings(self, **fields): + doc = frappe.new_doc("CRM Settings") + doc.update(fields) + return doc + + def test_data_sync_requires_at_least_one_allowed_user(self): + doc = self.make_settings(enable_frappe_crm_data_synchronization=1) + self.assertRaises(frappe.ValidationError, doc.validate_allowed_users) + # adding a user satisfies the check + doc.append("allowed_users", {"user": "Administrator"}) + doc.validate_allowed_users() + + def test_disabling_sync_clears_allowed_users(self): + doc = self.make_settings(enable_frappe_crm_data_synchronization=0) + doc.append("allowed_users", {"user": "Administrator"}) + doc.clear_allowed_users() + self.assertEqual(doc.allowed_users, []) + + # while sync is on, the rows are kept + enabled = self.make_settings(enable_frappe_crm_data_synchronization=1) + enabled.append("allowed_users", {"user": "Administrator"}) + enabled.clear_allowed_users() + self.assertEqual(len(enabled.allowed_users), 1) + + @ERPNextTestSuite.change_settings("Contact Us Settings", {"is_disabled": 1}) + def test_opportunity_from_contact_us_needs_the_form_enabled(self): + doc = self.make_settings(enable_opportunity_creation_from_contact_us=1) + self.assertRaises(frappe.ValidationError, doc.validate_enable_opportunity_creation_from_contact_us) From 34f3870f2aa794b25a8982cfc9c528a78a4e7fd0 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 17:28:28 +0530 Subject: [PATCH 07/20] test: add coverage for Contract Template validation and rendering --- .../test_contract_template.py | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/erpnext/crm/doctype/contract_template/test_contract_template.py b/erpnext/crm/doctype/contract_template/test_contract_template.py index 6362da2afb7..c690239856c 100644 --- a/erpnext/crm/doctype/contract_template/test_contract_template.py +++ b/erpnext/crm/doctype/contract_template/test_contract_template.py @@ -1,8 +1,43 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +import frappe + +from erpnext.crm.doctype.contract_template.contract_template import get_contract_template from erpnext.tests.utils import ERPNextTestSuite class TestContractTemplate(ERPNextTestSuite): - pass + """Contract Template validates its Jinja terms and renders them against a doc.""" + + def test_malformed_contract_terms_are_rejected(self): + doc = frappe.new_doc("Contract Template") + doc.contract_terms = "{% for x in %}" # invalid Jinja + self.assertRaises(frappe.ValidationError, doc.validate) + + # a valid template, and no template at all, both pass + doc.contract_terms = "Party: {{ party_name }}" + doc.validate() + doc.contract_terms = None + doc.validate() + + def test_get_contract_template_renders_terms(self): + template = frappe.get_doc( + { + "doctype": "Contract Template", + "title": "_Test Contract Template", + "contract_terms": "Party: {{ party_name }}", + } + ).insert() + + result = get_contract_template(template.name, {"party_name": "Acme"}) + self.assertEqual(result["contract_terms"], "Party: Acme") + self.assertEqual(result["contract_template"].name, template.name) + + def test_get_contract_template_without_terms_returns_none(self): + template = frappe.get_doc( + {"doctype": "Contract Template", "title": "_Test Empty Contract Template"} + ).insert() + + result = get_contract_template(template.name, {}) + self.assertIsNone(result["contract_terms"]) From 5eaafd3025b1710054e0699d0e78ce0ba1347d0d Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 17:33:35 +0530 Subject: [PATCH 08/20] test: add coverage for Campaign naming and UTM mirroring --- erpnext/crm/doctype/campaign/test_campaign.py | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/erpnext/crm/doctype/campaign/test_campaign.py b/erpnext/crm/doctype/campaign/test_campaign.py index 8876e640475..3e98474eed8 100644 --- a/erpnext/crm/doctype/campaign/test_campaign.py +++ b/erpnext/crm/doctype/campaign/test_campaign.py @@ -1,9 +1,31 @@ -# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe + +import frappe from erpnext.tests.utils import ERPNextTestSuite class TestCampaign(ERPNextTestSuite): - pass + """Campaign names itself from the campaign name (or a naming series) and mirrors + itself into a UTM Campaign.""" + + def setUp(self): + frappe.set_user("Administrator") + + def make_campaign(self, **fields): + doc = frappe.new_doc("Campaign") + doc.campaign_name = fields.pop("campaign_name", f"_Test Campaign {frappe.generate_hash(length=6)}") + doc.update(fields) + return doc.insert() + + def test_autoname_uses_the_campaign_name_by_default(self): + campaign = self.make_campaign(campaign_name="_Test Campaign Named") + self.assertEqual(campaign.name, "_Test Campaign Named") + + def test_inserting_mirrors_into_a_utm_campaign(self): + campaign = self.make_campaign(campaign_name="_Test Campaign UTM", description="Spring push") + self.assertTrue(frappe.db.exists("UTM Campaign", campaign.campaign_name)) + utm = frappe.get_doc("UTM Campaign", campaign.campaign_name) + self.assertEqual(utm.campaign_description, "Spring push") + self.assertEqual(utm.crm_campaign, campaign.campaign_name) From 9fdcfd5f588bc8de6e11666c96a4a9715406287a Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 17:40:16 +0530 Subject: [PATCH 09/20] fix: link UTM Campaign to the Campaign's document name, not campaign_name --- erpnext/crm/doctype/campaign/campaign.py | 16 +++++++--------- erpnext/crm/doctype/campaign/test_campaign.py | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/erpnext/crm/doctype/campaign/campaign.py b/erpnext/crm/doctype/campaign/campaign.py index f9834d2ccef..d4fd77a70f0 100644 --- a/erpnext/crm/doctype/campaign/campaign.py +++ b/erpnext/crm/doctype/campaign/campaign.py @@ -26,23 +26,21 @@ class Campaign(Document): # end: auto-generated types def after_insert(self): - try: - mc = frappe.get_doc("UTM Campaign", self.campaign_name) - except frappe.DoesNotExistError: - mc = frappe.new_doc("UTM Campaign") - mc.name = self.campaign_name - mc.campaign_description = self.description - mc.crm_campaign = self.campaign_name - mc.save(ignore_permissions=True) + self.sync_utm_campaign() def on_change(self): + self.sync_utm_campaign() + + def sync_utm_campaign(self): try: mc = frappe.get_doc("UTM Campaign", self.campaign_name) except frappe.DoesNotExistError: mc = frappe.new_doc("UTM Campaign") mc.name = self.campaign_name mc.campaign_description = self.description - mc.crm_campaign = self.campaign_name + # link to this Campaign by its document name, which differs from campaign_name + # when a naming series is used + mc.crm_campaign = self.name mc.save(ignore_permissions=True) def autoname(self): diff --git a/erpnext/crm/doctype/campaign/test_campaign.py b/erpnext/crm/doctype/campaign/test_campaign.py index 3e98474eed8..ed906c04aef 100644 --- a/erpnext/crm/doctype/campaign/test_campaign.py +++ b/erpnext/crm/doctype/campaign/test_campaign.py @@ -23,9 +23,22 @@ class TestCampaign(ERPNextTestSuite): campaign = self.make_campaign(campaign_name="_Test Campaign Named") self.assertEqual(campaign.name, "_Test Campaign Named") + def test_autoname_uses_naming_series_when_configured(self): + # regression: with a naming series the document name differs from campaign_name, + # and the UTM sync must still link back to a valid Campaign (self.name) + original = frappe.defaults.get_global_default("campaign_naming_by") + frappe.defaults.set_global_default("campaign_naming_by", "Naming Series") + try: + campaign = self.make_campaign(naming_series="SAL-CAM-.YYYY.-") + self.assertTrue(campaign.name.startswith("SAL-CAM-")) + utm = frappe.get_doc("UTM Campaign", campaign.campaign_name) + self.assertEqual(utm.crm_campaign, campaign.name) + finally: + frappe.defaults.set_global_default("campaign_naming_by", original or "") + def test_inserting_mirrors_into_a_utm_campaign(self): campaign = self.make_campaign(campaign_name="_Test Campaign UTM", description="Spring push") self.assertTrue(frappe.db.exists("UTM Campaign", campaign.campaign_name)) utm = frappe.get_doc("UTM Campaign", campaign.campaign_name) self.assertEqual(utm.campaign_description, "Spring push") - self.assertEqual(utm.crm_campaign, campaign.campaign_name) + self.assertEqual(utm.crm_campaign, campaign.name) From 10c6cda6db4bbfb0e8a9b276f41498a42caeb88d Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 17:47:22 +0530 Subject: [PATCH 10/20] fix: detect contained/enclosing date ranges in Stock Closing Entry duplicate check --- .../stock_closing_entry/stock_closing_entry.py | 9 ++++----- .../stock_closing_entry/test_stock_closing_entry.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py b/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py index 00a3b0204c4..1f8450c87c4 100644 --- a/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py +++ b/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py @@ -59,11 +59,10 @@ class StockClosingEntry(Document): .where( (table.docstatus == 1) & (table.company == self.company) - & ( - (table.from_date.between(self.from_date, self.to_date)) - | (table.to_date.between(self.from_date, self.to_date)) - | ((self.from_date >= table.from_date) & (table.from_date >= self.to_date)) - ) + # two date ranges overlap when each starts on or before the other ends; + # this also catches one range being fully contained within the other + & (table.from_date <= self.to_date) + & (table.to_date >= self.from_date) ) ) diff --git a/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py b/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py index 2f47551ff74..df5c22b6be5 100644 --- a/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py +++ b/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py @@ -84,6 +84,18 @@ class TestStockClosingEntryDuplicate(ERPNextTestSuite): overlap = self.make_closing("2026-02-01", "2026-04-30") self.assertRaises(frappe.ValidationError, overlap.insert) + def test_fully_contained_range_is_rejected(self): + # a range entirely inside an existing entry's range is still a duplicate + self.submit_closing(self.make_closing("2026-01-01", "2026-12-31")) + contained = self.make_closing("2026-03-01", "2026-03-31") + self.assertRaises(frappe.ValidationError, contained.insert) + + def test_enclosing_range_is_rejected(self): + # and so is a range that fully encloses an existing entry's range + self.submit_closing(self.make_closing("2026-03-01", "2026-03-31")) + enclosing = self.make_closing("2026-01-01", "2026-12-31") + self.assertRaises(frappe.ValidationError, enclosing.insert) + def test_non_overlapping_range_is_allowed(self): self.submit_closing(self.make_closing("2026-01-01", "2026-03-31")) later = self.make_closing("2026-04-01", "2026-06-30") From e5b7c3f98c5d844961db68d6c7f6ceca202ccfef Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 3 Jul 2026 16:44:48 +0530 Subject: [PATCH 11/20] test: cover Job Card quantity, docstatus and capacity-overlap logic --- .../doctype/job_card/test_job_card.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index bf10aa0e3f0..efb1636e7c1 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1703,3 +1703,76 @@ def create_semi_fg_bom(semi_fg_item, raw_item, inspection_required): bom.append("items", {"item_code": raw_item, "qty": 1}) bom.submit() return bom.name + + +class TestJobCardLogic(ERPNextTestSuite): + """Field-level validations and pure quantity/capacity helpers, exercised on the + document directly so they don't need a Work Order / BOM (the integration suite does).""" + + def test_processing_a_submitted_or_cancelled_card_is_blocked(self): + submitted = frappe.new_doc("Job Card") + submitted.docstatus = 1 + self.assertRaises(frappe.ValidationError, submitted.validate_docstatus) + + cancelled = frappe.new_doc("Job Card") + cancelled.docstatus = 2 + self.assertRaises(frappe.ValidationError, cancelled.validate_docstatus) + + def test_complete_job_card_qty_guards(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 5 + jc.validate_complete_job_card_qty(frappe._dict(pending_qty=3)) # within range -> passes + self.assertRaises( + frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=-1) + ) + self.assertRaises( + frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(process_loss_qty=-1) + ) + self.assertRaises( + frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=10) + ) + + def test_completed_qty_must_reconcile_with_for_quantity(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 10 + jc.total_completed_qty = 6 + jc.process_loss_qty = 0 + jc.pending_qty = 0 + # 6 + 0 + 0 != 10 -> throws + self.assertRaises(frappe.ValidationError, jc.validate_completed_qty_matches_for_quantity) + # completed + loss + pending == for_quantity -> passes + jc.pending_qty = 4 + jc.validate_completed_qty_matches_for_quantity() + + def test_set_process_loss(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 10 + jc.total_completed_qty = 6 + jc.pending_qty = 1 + jc.set_process_loss() + self.assertEqual(jc.process_loss_qty, 3) # 10 - 6 - 1 + + # no loss when nothing completed yet + nothing_done = frappe.new_doc("Job Card") + nothing_done.for_quantity = 10 + nothing_done.total_completed_qty = 0 + nothing_done.set_process_loss() + self.assertEqual(nothing_done.process_loss_qty, 0) + + def test_capacity_overlap_detection(self): + jc = frappe.new_doc("Job Card") + sequential = [ + {"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"}, + {"from_time": "2026-01-01 11:00:00", "to_time": "2026-01-01 12:00:00"}, + ] + overlapping = [ + {"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"}, + {"from_time": "2026-01-01 10:30:00", "to_time": "2026-01-01 11:30:00"}, + ] + # sequential logs share one capacity slot; overlapping logs need two + self.assertEqual(len(jc.get_alloted_capacity(sequential)), 1) + self.assertEqual(len(jc.get_alloted_capacity(overlapping)), 2) + # capacity 1 overlaps with any log; capacity 2 only when both slots are taken + self.assertTrue(jc.has_overlap(1, sequential)) + self.assertFalse(jc.has_overlap(2, sequential)) + self.assertTrue(jc.has_overlap(2, overlapping)) From 99ed620dadb054530713f54df9905a8985776a5e Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 17:54:46 +0530 Subject: [PATCH 12/20] fix: reset_mode_of_payments raises AttributeError on POS Invoice --- .../pos_invoice/test_pos_invoice_reset_mop.py | 36 +++++++++++++++++++ .../doctype/sales_invoice/services/pos.py | 4 ++- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 erpnext/accounts/doctype/pos_invoice/test_pos_invoice_reset_mop.py diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice_reset_mop.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice_reset_mop.py new file mode 100644 index 00000000000..623211a6ed2 --- /dev/null +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice_reset_mop.py @@ -0,0 +1,36 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# Regression test for https://github.com/frappe/erpnext/issues/56501 +# AttributeError: 'POSInvoice' object has no attribute 'is_created_using_pos' +# when calling reset_mode_of_payments on a draft POS Invoice. + +import frappe + +from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import ( + POSInvoiceTestMixin, + create_pos_invoice, +) +from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry + + +class TestPOSInvoiceResetModeOfPayments(POSInvoiceTestMixin): + def setUp(self): + super().setUp() + create_opening_entry(self.pos_profile, self.test_user.name) + + def test_reset_mode_of_payments_does_not_raise_attribute_error(self): + """Calling reset_mode_of_payments on a draft POS Invoice must not raise + AttributeError for the missing is_created_using_pos attribute. + + update_multi_mode_option accesses doc.is_created_using_pos, which is a + field on SalesInvoice but does not exist on POSInvoice, causing the error + reported in #56501 when a user tries to edit a saved draft order. + """ + inv = create_pos_invoice(do_not_submit=True) + + # This call must not raise AttributeError on the missing field. + inv.reset_mode_of_payments() + + # Payments should have been repopulated from the POS profile. + self.assertTrue(len(inv.payments) > 0, "Payments should be populated after reset") diff --git a/erpnext/accounts/doctype/sales_invoice/services/pos.py b/erpnext/accounts/doctype/sales_invoice/services/pos.py index 9c7a7c2654c..76fc770de47 100644 --- a/erpnext/accounts/doctype/sales_invoice/services/pos.py +++ b/erpnext/accounts/doctype/sales_invoice/services/pos.py @@ -344,7 +344,9 @@ def update_multi_mode_option(doc, pos_profile) -> None: payment.account = payment_mode.default_account payment.type = payment_mode.type - mop_refetched = bool(doc.payments) and not doc.is_created_using_pos + # is_created_using_pos exists on Sales Invoice but not POS Invoice; use get() so this + # shared helper doesn't raise AttributeError when called on a POS Invoice + mop_refetched = bool(doc.payments) and not doc.get("is_created_using_pos") doc.set("payments", []) invalid_modes = [] From a1f6ae56ff0c988860fd5715b3799abeb5e22b21 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 18:39:52 +0530 Subject: [PATCH 13/20] fix: removed unused import Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../accounts/doctype/pos_invoice/test_pos_invoice_reset_mop.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice_reset_mop.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice_reset_mop.py index 623211a6ed2..0f0f6052576 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice_reset_mop.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice_reset_mop.py @@ -5,8 +5,6 @@ # AttributeError: 'POSInvoice' object has no attribute 'is_created_using_pos' # when calling reset_mode_of_payments on a draft POS Invoice. -import frappe - from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import ( POSInvoiceTestMixin, create_pos_invoice, From ca9dcbf2d70a0500bae44cccc3ce8cea6d38fb29 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 18:45:58 +0530 Subject: [PATCH 14/20] fix: reuse the existing UTM Campaign mirror when campaign_name is edited --- erpnext/crm/doctype/campaign/campaign.py | 8 +++++--- erpnext/crm/doctype/campaign/test_campaign.py | 8 ++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/erpnext/crm/doctype/campaign/campaign.py b/erpnext/crm/doctype/campaign/campaign.py index d4fd77a70f0..e89b1aa79f6 100644 --- a/erpnext/crm/doctype/campaign/campaign.py +++ b/erpnext/crm/doctype/campaign/campaign.py @@ -32,14 +32,16 @@ class Campaign(Document): self.sync_utm_campaign() def sync_utm_campaign(self): + # look up the existing mirror by the stable Campaign link first, so editing + # campaign_name updates that mirror instead of creating a duplicate + existing = frappe.db.get_value("UTM Campaign", {"crm_campaign": self.name}) or self.campaign_name try: - mc = frappe.get_doc("UTM Campaign", self.campaign_name) + mc = frappe.get_doc("UTM Campaign", existing) except frappe.DoesNotExistError: mc = frappe.new_doc("UTM Campaign") mc.name = self.campaign_name mc.campaign_description = self.description - # link to this Campaign by its document name, which differs from campaign_name - # when a naming series is used + # link by the document name, which differs from campaign_name when a naming series is used mc.crm_campaign = self.name mc.save(ignore_permissions=True) diff --git a/erpnext/crm/doctype/campaign/test_campaign.py b/erpnext/crm/doctype/campaign/test_campaign.py index ed906c04aef..169ecc75765 100644 --- a/erpnext/crm/doctype/campaign/test_campaign.py +++ b/erpnext/crm/doctype/campaign/test_campaign.py @@ -42,3 +42,11 @@ class TestCampaign(ERPNextTestSuite): utm = frappe.get_doc("UTM Campaign", campaign.campaign_name) self.assertEqual(utm.campaign_description, "Spring push") self.assertEqual(utm.crm_campaign, campaign.name) + + def test_editing_campaign_name_reuses_the_same_utm_campaign(self): + campaign = self.make_campaign(campaign_name="_Test Campaign Rename A") + campaign.campaign_name = "_Test Campaign Rename B" + campaign.save() + # the edit updates the existing mirror rather than creating a second one + mirrors = frappe.get_all("UTM Campaign", filters={"crm_campaign": campaign.name}) + self.assertEqual(len(mirrors), 1) From 2b1e922183f9dfe8f0776b40760aef2d9d0f53c2 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 19:42:13 +0530 Subject: [PATCH 15/20] fix: don't hijack another Campaign's UTM mirror when display names collide --- erpnext/crm/doctype/campaign/campaign.py | 26 +++++++++++++------ erpnext/crm/doctype/campaign/test_campaign.py | 18 +++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/erpnext/crm/doctype/campaign/campaign.py b/erpnext/crm/doctype/campaign/campaign.py index e89b1aa79f6..4953be544a5 100644 --- a/erpnext/crm/doctype/campaign/campaign.py +++ b/erpnext/crm/doctype/campaign/campaign.py @@ -32,19 +32,29 @@ class Campaign(Document): self.sync_utm_campaign() def sync_utm_campaign(self): - # look up the existing mirror by the stable Campaign link first, so editing - # campaign_name updates that mirror instead of creating a duplicate - existing = frappe.db.get_value("UTM Campaign", {"crm_campaign": self.name}) or self.campaign_name - try: - mc = frappe.get_doc("UTM Campaign", existing) - except frappe.DoesNotExistError: - mc = frappe.new_doc("UTM Campaign") - mc.name = self.campaign_name + mc = self.get_utm_campaign_mirror() mc.campaign_description = self.description # link by the document name, which differs from campaign_name when a naming series is used mc.crm_campaign = self.name mc.save(ignore_permissions=True) + def get_utm_campaign_mirror(self): + # the mirror already linked to this Campaign, if any (survives campaign_name edits) + if owned := frappe.db.get_value("UTM Campaign", {"crm_campaign": self.name}): + return frappe.get_doc("UTM Campaign", owned) + + # reuse a same-named mirror only when it isn't already owned by another Campaign, + # otherwise two Campaigns sharing a display name would hijack each other's mirror + if frappe.db.exists("UTM Campaign", self.campaign_name): + same_name = frappe.get_doc("UTM Campaign", self.campaign_name) + if not same_name.crm_campaign or same_name.crm_campaign == self.name: + return same_name + + # create a fresh mirror, keeping its name unique when the display name is taken + mc = frappe.new_doc("UTM Campaign") + mc.name = self.name if frappe.db.exists("UTM Campaign", self.campaign_name) else self.campaign_name + return mc + def autoname(self): if frappe.defaults.get_global_default("campaign_naming_by") != "Naming Series": self.name = self.campaign_name diff --git a/erpnext/crm/doctype/campaign/test_campaign.py b/erpnext/crm/doctype/campaign/test_campaign.py index 169ecc75765..e3f41e9958f 100644 --- a/erpnext/crm/doctype/campaign/test_campaign.py +++ b/erpnext/crm/doctype/campaign/test_campaign.py @@ -50,3 +50,21 @@ class TestCampaign(ERPNextTestSuite): # the edit updates the existing mirror rather than creating a second one mirrors = frappe.get_all("UTM Campaign", filters={"crm_campaign": campaign.name}) self.assertEqual(len(mirrors), 1) + + def test_two_campaigns_sharing_a_name_do_not_hijack_each_others_mirror(self): + # a naming series lets two Campaigns share a display name; each must keep its own mirror + original = frappe.defaults.get_global_default("campaign_naming_by") + frappe.defaults.set_global_default("campaign_naming_by", "Naming Series") + try: + first = self.make_campaign(campaign_name="_Test Shared Mirror", naming_series="SAL-CAM-.YYYY.-") + second = self.make_campaign(campaign_name="_Test Shared Mirror", naming_series="SAL-CAM-.YYYY.-") + finally: + frappe.defaults.set_global_default("campaign_naming_by", original or "") + + # the first Campaign's mirror is untouched; the second gets a distinct one + self.assertEqual( + frappe.db.get_value("UTM Campaign", "_Test Shared Mirror", "crm_campaign"), first.name + ) + second_mirror = frappe.db.get_value("UTM Campaign", {"crm_campaign": second.name}) + self.assertTrue(second_mirror) + self.assertNotEqual(second_mirror, "_Test Shared Mirror") From a9bb6b31df8976bb8d708f2474beef17f6aab8ef Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 18:00:19 +0530 Subject: [PATCH 16/20] fix: use transaction-currency outstanding on Dunning for foreign-currency invoices When a Sales Invoice is in a foreign currency (e.g. USD) but the receivable account is in the company currency (e.g. INR), `outstanding_amount` on the invoice is stored in the party account currency (INR). `postprocess_dunning` was copying that value directly into the Dunning's Overdue Payment row, which is expected to carry the transaction-currency (USD) amount. The fix: when `party_account_currency != currency`, use `payment_schedule[0].outstanding` (already maintained in transaction currency) instead of `outstanding_amount`. Closes #56006 --- .../accounts/doctype/dunning/test_dunning.py | 32 +++++++++++++++++++ .../accounts/doctype/sales_invoice/mapper.py | 10 +++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py index 0110877ce90..4508738a471 100644 --- a/erpnext/accounts/doctype/dunning/test_dunning.py +++ b/erpnext/accounts/doctype/dunning/test_dunning.py @@ -12,6 +12,7 @@ from erpnext.accounts.doctype.sales_invoice.mapper import ( create_dunning as create_dunning_from_sales_invoice, ) from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import ( + create_sales_invoice, create_sales_invoice_against_cost_center, ) from erpnext.tests.utils import ERPNextTestSuite @@ -152,6 +153,37 @@ class TestDunning(ERPNextTestSuite): dunning.reload() self.assertEqual(dunning.status, "Unresolved") + @ERPNextTestSuite.change_settings( + "Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1} + ) + def test_dunning_outstanding_uses_transaction_currency(self): + """ + Regression for #56006: dunning outstanding must be in the invoice transaction + currency, not in the party account currency. + + A USD invoice posted against an INR receivable account stores + outstanding_amount in INR (party account currency). The overdue payment + row on the resulting Dunning must carry the USD amount, not the INR amount. + """ + si = create_sales_invoice( + posting_date=add_days(today(), -10), + currency="USD", + conversion_rate=50, + rate=100, + debit_to="Debtors - _TC", + ) + + # Sanity-check the invoice state before creating the dunning + self.assertEqual(si.currency, "USD") + self.assertEqual(si.outstanding_amount, 5000.0) # INR (party account currency) + self.assertEqual(si.payment_schedule[0].outstanding, 100.0) # USD (transaction currency) + + dunning = create_dunning_from_sales_invoice(si.name) + + self.assertEqual(len(dunning.overdue_payments), 1) + # Must reflect 100 USD, not 5000 INR mislabelled as USD + self.assertEqual(dunning.overdue_payments[0].outstanding, 100.0) + def test_dunning_not_affected_by_standalone_credit_note(self): """ Test that dunning is NOT resolved when a credit note has update_outstanding_for_self checked. diff --git a/erpnext/accounts/doctype/sales_invoice/mapper.py b/erpnext/accounts/doctype/sales_invoice/mapper.py index 8a8d07dea1d..46ce4753a85 100644 --- a/erpnext/accounts/doctype/sales_invoice/mapper.py +++ b/erpnext/accounts/doctype/sales_invoice/mapper.py @@ -594,7 +594,15 @@ def create_dunning( if source.payment_schedule and len(source.payment_schedule) == 1: for row in target.overdue_payments: if row.payment_schedule == source.payment_schedule[0].name: - row.outstanding = source.get("outstanding_amount") + # outstanding_amount is in the party account currency, but the Overdue Payment + # row is in the invoice's transaction currency. When they differ, use the + # payment schedule's own outstanding — it is kept in transaction currency and + # updated as payments are allocated, so it stays correct even when the invoice + # and its payments post at different exchange rates (#56006). + if source.party_account_currency and source.party_account_currency != source.currency: + row.outstanding = source.payment_schedule[0].outstanding + else: + row.outstanding = source.get("outstanding_amount") target.validate() From 29be72fae4dea8e7f5dcced4043d9b186a4c17e4 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Sat, 4 Jul 2026 23:29:32 +0530 Subject: [PATCH 17/20] fix: warning message for new item standard cost (#56885) --- .../item_standard_cost/item_standard_cost.js | 29 +++++++++++++++++++ .../item_standard_cost/item_standard_cost.py | 19 ++++++++++++ 2 files changed, 48 insertions(+) diff --git a/erpnext/stock/doctype/item_standard_cost/item_standard_cost.js b/erpnext/stock/doctype/item_standard_cost/item_standard_cost.js index f867de3ab67..1d7267732b0 100644 --- a/erpnext/stock/doctype/item_standard_cost/item_standard_cost.js +++ b/erpnext/stock/doctype/item_standard_cost/item_standard_cost.js @@ -13,4 +13,33 @@ frappe.ui.form.on("Item Standard Cost", { }; }); }, + + refresh(frm) { + frm.trigger("show_backdated_block_warning"); + }, + + item_code(frm) { + frm.trigger("show_backdated_block_warning"); + }, + + effective_date(frm) { + frm.trigger("show_backdated_block_warning"); + }, + + show_backdated_block_warning(frm) { + if (frm.doc.docstatus !== 0 || !frm.doc.item_code || !frm.doc.effective_date) { + frm.set_intro(""); + return; + } + frm.set_intro( + __( + "On submission, stock transactions for Item {0} cannot be posted with a date before {1} — backdated entries will be blocked.", + [ + frappe.utils.escape_html(frm.doc.item_code).bold(), + frappe.datetime.str_to_user(frm.doc.effective_date).bold(), + ] + ), + "yellow" + ); + }, }); diff --git a/erpnext/stock/doctype/item_standard_cost/item_standard_cost.py b/erpnext/stock/doctype/item_standard_cost/item_standard_cost.py index 62fb8e02903..c1a7a35506b 100644 --- a/erpnext/stock/doctype/item_standard_cost/item_standard_cost.py +++ b/erpnext/stock/doctype/item_standard_cost/item_standard_cost.py @@ -33,6 +33,25 @@ class ItemStandardCost(Document): self.validate_item() self.validate_effective_date() self.validate_rate() + self.warn_backdated_transactions_will_be_blocked() + + def warn_backdated_transactions_will_be_blocked(self): + # Heads-up while creating (R2 enforces it later on every stock voucher): once this rate is + # effective, the item's stock transactions cannot be dated before the effective date. + if not self.is_new(): + return + frappe.msgprint( + _( + "Once this Standard Cost is submitted, stock transactions for Item {0} in {1} cannot be posted with a date before the Effective Date {2}. Post any backdated entries before submitting." + ).format( + get_link_to_form("Item", self.item_code), + frappe.bold(self.company), + frappe.bold(frappe.format(self.effective_date, "Date")), + ), + title=_("Backdated Entries Will Be Blocked"), + indicator="orange", + alert=True, + ) def validate_item(self): if not frappe.get_cached_value("Item", self.item_code, "is_stock_item"): From c293cb88718b2033945dfd7077691e253d08d573 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 5 Jul 2026 12:11:34 +0530 Subject: [PATCH 18/20] test: add coverage for Lower Deduction Certificate date validation --- .../test_lower_deduction_certificate.py | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/erpnext/regional/doctype/lower_deduction_certificate/test_lower_deduction_certificate.py b/erpnext/regional/doctype/lower_deduction_certificate/test_lower_deduction_certificate.py index 90396e4e2bf..c38636d4541 100644 --- a/erpnext/regional/doctype/lower_deduction_certificate/test_lower_deduction_certificate.py +++ b/erpnext/regional/doctype/lower_deduction_certificate/test_lower_deduction_certificate.py @@ -1,9 +1,48 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe +import frappe +from frappe.utils import add_days, getdate, today + +from erpnext.accounts.utils import get_fiscal_year from erpnext.tests.utils import ERPNextTestSuite class TestLowerDeductionCertificate(ERPNextTestSuite): - pass + """The certificate validates its date range and detects overlap with an existing + certificate for the same supplier/category.""" + + def make_ldc(self, valid_from, valid_upto, fiscal_year=None): + doc = frappe.new_doc("Lower Deduction Certificate") + doc.valid_from = valid_from + doc.valid_upto = valid_upto + doc.fiscal_year = fiscal_year + return doc + + def dup(self, valid_from, valid_upto): + return frappe._dict(valid_from=getdate(valid_from), valid_upto=getdate(valid_upto)) + + def test_are_dates_overlapping(self): + # existing certificate spans Mar 1 - Jun 30 + existing = self.dup("2026-03-01", "2026-06-30") + + # new period starts inside the existing one + self.assertTrue(self.make_ldc("2026-05-01", "2026-08-31").are_dates_overlapping(existing)) + # new period ends inside the existing one + self.assertTrue(self.make_ldc("2026-01-01", "2026-04-30").are_dates_overlapping(existing)) + # new period fully envelops the existing one + self.assertTrue(self.make_ldc("2026-01-01", "2026-12-31").are_dates_overlapping(existing)) + # new period is entirely after the existing one -> no overlap + self.assertFalse(self.make_ldc("2026-07-01", "2026-12-31").are_dates_overlapping(existing)) + + def test_valid_upto_cannot_precede_valid_from(self): + doc = self.make_ldc(valid_from="2026-06-30", valid_upto="2026-01-01") + self.assertRaises(frappe.ValidationError, doc.validate_dates) + + def test_dates_must_fall_within_the_fiscal_year(self): + fy_name, fy_start, fy_end = get_fiscal_year(today()) + # a range inside the fiscal year is accepted + self.make_ldc(fy_start, fy_end, fiscal_year=fy_name).validate_dates() + # a valid_from before the fiscal year start is rejected + before_fy = self.make_ldc(add_days(fy_start, -1), fy_end, fiscal_year=fy_name) + self.assertRaises(frappe.ValidationError, before_fy.validate_dates) From f2f1b2597dd323203d2ad99bddf65599ed614744 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 5 Jul 2026 12:13:48 +0530 Subject: [PATCH 19/20] test: add coverage for Italy e-invoice utility helpers --- erpnext/regional/italy/test_utils.py | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 erpnext/regional/italy/test_utils.py diff --git a/erpnext/regional/italy/test_utils.py b/erpnext/regional/italy/test_utils.py new file mode 100644 index 00000000000..f716bba8719 --- /dev/null +++ b/erpnext/regional/italy/test_utils.py @@ -0,0 +1,86 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import types + +import frappe + +from erpnext.regional.italy.utils import ( + append_row_as_charges, + get_conditions, + get_unamended_name, + update_summary_details, +) +from erpnext.tests.utils import ERPNextTestSuite + + +class TestItalyUtils(ERPNextTestSuite): + """Pure helpers behind the Italian e-invoice export.""" + + def test_get_conditions_builds_filter_map(self): + base = get_conditions({}) + self.assertEqual(base["docstatus"], 1) + self.assertEqual(base["company_tax_id"], ("!=", "")) + self.assertNotIn("company", base) + + scoped = get_conditions({"company": "_Test Company", "customer": "_Test Customer"}) + self.assertEqual(scoped["company"], "_Test Company") + self.assertEqual(scoped["customer"], "_Test Customer") + + # a single bound uses >=/<=, both bounds use a between range + self.assertEqual(get_conditions({"from_date": "2026-01-01"})["posting_date"], (">=", "2026-01-01")) + self.assertEqual(get_conditions({"to_date": "2026-06-30"})["posting_date"], ("<=", "2026-06-30")) + self.assertEqual( + get_conditions({"from_date": "2026-01-01", "to_date": "2026-06-30"})["posting_date"], + ("between", ["2026-01-01", "2026-06-30"]), + ) + + def test_update_summary_details_accumulates_and_flags_exemption(self): + summary = {} + tax = frappe._dict(tax_exemption_reason="N4", tax_exemption_law="Art. 10") + + update_summary_details(summary, tax, 22.0, 44.0, 200.0) + update_summary_details(summary, tax, 22.0, 22.0, 100.0) + self.assertEqual(summary["22.0"]["tax_amount"], 66.0) + self.assertEqual(summary["22.0"]["taxable_amount"], 300.0) + # exemption fields are only populated for the zero-rate bucket + self.assertEqual(summary["22.0"]["tax_exemption_reason"], "") + + update_summary_details(summary, tax, 0.0, 0.0, 500.0) + self.assertEqual(summary["0.0"]["tax_exemption_reason"], "N4") + self.assertEqual(summary["0.0"]["tax_exemption_law"], "Art. 10") + + def test_append_row_as_charges_computes_amount(self): + items, summary = [], {} + tax = frappe._dict(rate=22.0, account_head="VAT - IT", tax_exemption_reason="", tax_exemption_law="") + reference_row = frappe._dict(tax_amount=200.0, description="Consulting") + + append_row_as_charges(items, tax, reference_row, summary) + + self.assertEqual(len(items), 1) + row = items[0] + self.assertEqual(row.tax_rate, 22.0) + self.assertEqual(row.tax_amount, 44.0) # 200 * 22 / 100 + self.assertEqual(row.taxable_amount, 200.0) + self.assertEqual(row.item_code, "Consulting") + self.assertEqual(row.item_tax_rate, {"VAT - IT": 22.0}) + self.assertEqual(summary["22.0"]["tax_amount"], 44.0) + + def test_get_unamended_name(self): + # a doc missing the naming attributes is returned unchanged + plain = types.SimpleNamespace(name="ACC-SINV-2026-00001") + self.assertEqual(get_unamended_name(plain), "ACC-SINV-2026-00001") + + # an amended doc drops the trailing amendment suffix + amended = frappe._dict( + name="ACC-SINV-2026-00001-1", + naming_series="ACC-SINV-.YYYY.-", + amended_from="ACC-SINV-2026-00001", + ) + self.assertEqual(get_unamended_name(amended), "ACC-SINV-2026-00001") + + # an original (non-amended) doc keeps its name + original = frappe._dict( + name="ACC-SINV-2026-00001", naming_series="ACC-SINV-.YYYY.-", amended_from=None + ) + self.assertEqual(get_unamended_name(original), "ACC-SINV-2026-00001") From 616ceb81265d05453a9e67cd8169b0c9a457c2ba Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 5 Jul 2026 12:16:09 +0530 Subject: [PATCH 20/20] test: add coverage for Import Supplier Invoice validation and country lookup --- .../test_import_supplier_invoice.py | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/erpnext/regional/doctype/import_supplier_invoice/test_import_supplier_invoice.py b/erpnext/regional/doctype/import_supplier_invoice/test_import_supplier_invoice.py index 77143d5b9ab..a955839d1a3 100644 --- a/erpnext/regional/doctype/import_supplier_invoice/test_import_supplier_invoice.py +++ b/erpnext/regional/doctype/import_supplier_invoice/test_import_supplier_invoice.py @@ -1,9 +1,27 @@ -# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe +import frappe + +from erpnext.regional.doctype.import_supplier_invoice.import_supplier_invoice import get_country from erpnext.tests.utils import ERPNextTestSuite class TestImportSupplierInvoice(ERPNextTestSuite): - pass + """The importer requires a default stock UOM and resolves country codes from the file.""" + + @ERPNextTestSuite.change_settings("Stock Settings", {"stock_uom": ""}) + def test_validate_requires_a_default_uom(self): + doc = frappe.new_doc("Import Supplier Invoice") + self.assertRaises(frappe.ValidationError, doc.validate) + + @ERPNextTestSuite.change_settings("Stock Settings", {"stock_uom": "Nos"}) + def test_validate_passes_with_a_default_uom(self): + frappe.new_doc("Import Supplier Invoice").validate() + + def test_get_country_resolves_a_known_code(self): + country = frappe.get_all("Country", filters={"code": ["!=", ""]}, fields=["name", "code"], limit=1)[0] + self.assertEqual(get_country(country.code), country.name) + + def test_get_country_rejects_an_unknown_code(self): + self.assertRaises(frappe.ValidationError, get_country, "__no_such_country_code__")