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/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..0f0f6052576 --- /dev/null +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice_reset_mop.py @@ -0,0 +1,34 @@ +# 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. + +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/repost_payment_ledger/test_repost_payment_ledger.py b/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py index 8c2b8946121..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 @@ -1,11 +1,55 @@ -# 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): + 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(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") + + def test_add_manually_preserves_user_rows(self): + # 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, 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] + self.assertEqual(rows, [("Sales Invoice", manual_si.name)]) 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() 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 = [] diff --git a/erpnext/crm/doctype/campaign/campaign.py b/erpnext/crm/doctype/campaign/campaign.py index f9834d2ccef..4953be544a5 100644 --- a/erpnext/crm/doctype/campaign/campaign.py +++ b/erpnext/crm/doctype/campaign/campaign.py @@ -26,25 +26,35 @@ 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): - try: - mc = frappe.get_doc("UTM Campaign", self.campaign_name) - except frappe.DoesNotExistError: - mc = frappe.new_doc("UTM Campaign") - mc.name = self.campaign_name + self.sync_utm_campaign() + + def sync_utm_campaign(self): + mc = self.get_utm_campaign_mirror() mc.campaign_description = self.description - mc.crm_campaign = self.campaign_name + # 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 8876e640475..e3f41e9958f 100644 --- a/erpnext/crm/doctype/campaign/test_campaign.py +++ b/erpnext/crm/doctype/campaign/test_campaign.py @@ -1,9 +1,70 @@ -# 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_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.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) + + 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") 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"]) 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) 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)) 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__") 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) 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") 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"): 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 3485e47030f..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 @@ -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,45 @@ 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_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") + later.insert() # would raise if validate_duplicate wrongly flagged it as overlapping + self.assertTrue(frappe.db.exists("Stock Closing Entry", later.name))