Merge branch 'develop' into chore/test-appointment-booking-settings

This commit is contained in:
Nabin Hait
2026-07-05 12:50:53 +05:30
committed by GitHub
17 changed files with 602 additions and 39 deletions

View File

@@ -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.

View File

@@ -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")

View File

@@ -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)])

View File

@@ -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()

View File

@@ -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 = []

View File

@@ -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

View File

@@ -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")

View File

@@ -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"])

View File

@@ -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)

View File

@@ -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))

View File

@@ -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__")

View File

@@ -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)

View File

@@ -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")

View File

@@ -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"
);
},
});

View File

@@ -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"):

View File

@@ -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)
)
)

View File

@@ -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))