mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-12 22:21:50 +00:00
Merge branch 'develop' into chore/test-appointment-booking-settings
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
@@ -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)])
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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__")
|
||||
|
||||
@@ -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)
|
||||
|
||||
86
erpnext/regional/italy/test_utils.py
Normal file
86
erpnext/regional/italy/test_utils.py
Normal 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")
|
||||
@@ -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"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user