diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 68cdf2dca17..e00711ccf42 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -506,3 +506,4 @@ erpnext.patches.v16_0.rename_secondary_item_type_field erpnext.patches.v16_0.append_fieldname_to_pos_search_fields erpnext.patches.v16_0.set_secondary_item_valuation_type erpnext.patches.v16_0.add_transaction_roles_to_sms_settings +erpnext.patches.v16_0.recalculate_holiday_list_totals diff --git a/erpnext/patches/v16_0/recalculate_holiday_list_totals.py b/erpnext/patches/v16_0/recalculate_holiday_list_totals.py new file mode 100644 index 00000000000..3df47194530 --- /dev/null +++ b/erpnext/patches/v16_0/recalculate_holiday_list_totals.py @@ -0,0 +1,18 @@ +import frappe +from frappe.query_builder import Case +from frappe.query_builder.functions import Coalesce, Sum + + +def execute(): + holiday_list = frappe.qb.DocType("Holiday List") + holiday = frappe.qb.DocType("Holiday") + total_holidays = ( + frappe.qb.from_(holiday) + .select(Sum(Case().when(holiday.is_half_day == 1, 0.5).else_(1))) + .where( + (holiday.parent == holiday_list.name) + & (holiday.parenttype == "Holiday List") + & (holiday.parentfield == "holidays") + ) + ) + frappe.qb.update(holiday_list).set(holiday_list.total_holidays, Coalesce(total_holidays, 0)).run() diff --git a/erpnext/setup/doctype/holiday_list/holiday_list.js b/erpnext/setup/doctype/holiday_list/holiday_list.js index 1f95a73c242..3dabb525625 100644 --- a/erpnext/setup/doctype/holiday_list/holiday_list.js +++ b/erpnext/setup/doctype/holiday_list/holiday_list.js @@ -1,11 +1,18 @@ // Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt +function update_total_holidays(frm) { + let total_holidays = 0; + for (const holiday of frm.doc.holidays || []) { + total_holidays += holiday.is_half_day ? 0.5 : 1; + } + frm.doc.total_holidays = total_holidays; + frm.refresh_field("total_holidays"); +} + frappe.ui.form.on("Holiday List", { refresh: function (frm) { - if (frm.doc.holidays) { - frm.set_value("total_holidays", frm.doc.holidays.length); - } + update_total_holidays(frm); frm.call("get_supported_countries").then((r) => { frm.subdivisions_by_country = r.message.subdivisions_by_country; @@ -43,6 +50,18 @@ frappe.ui.form.on("Holiday List", { }, }); +frappe.ui.form.on("Holiday", { + holidays_add: function (frm) { + update_total_holidays(frm); + }, + holidays_remove: function (frm) { + update_total_holidays(frm); + }, + is_half_day: function (frm) { + update_total_holidays(frm); + }, +}); + frappe.tour["Holiday List"] = [ { fieldname: "holiday_list_name", diff --git a/erpnext/setup/doctype/holiday_list/holiday_list.json b/erpnext/setup/doctype/holiday_list/holiday_list.json index fb4feba4872..c7ab6f00e5c 100644 --- a/erpnext/setup/doctype/holiday_list/holiday_list.json +++ b/erpnext/setup/doctype/holiday_list/holiday_list.json @@ -58,9 +58,10 @@ }, { "fieldname": "total_holidays", - "fieldtype": "Int", + "fieldtype": "Float", "in_list_view": 1, "label": "Total Holidays", + "precision": "1", "read_only": 1 }, { diff --git a/erpnext/setup/doctype/holiday_list/holiday_list.py b/erpnext/setup/doctype/holiday_list/holiday_list.py index 74be12fd4a9..f544f2fc37f 100644 --- a/erpnext/setup/doctype/holiday_list/holiday_list.py +++ b/erpnext/setup/doctype/holiday_list/holiday_list.py @@ -8,7 +8,7 @@ from datetime import date import frappe from frappe import _, throw from frappe.model.document import Document -from frappe.utils import formatdate, getdate, today +from frappe.utils import cint, formatdate, getdate, today class OverlapError(frappe.ValidationError): @@ -34,7 +34,7 @@ class HolidayList(Document): is_half_day: DF.Check subdivision: DF.Autocomplete | None to_date: DF.Date - total_holidays: DF.Int + total_holidays: DF.Float weekly_off: DF.Literal[ "", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ] @@ -42,10 +42,13 @@ class HolidayList(Document): def validate(self): self.validate_days() - self.total_holidays = len(self.holidays) + self.update_total_holidays() self.validate_duplicate_date() self.sort_holidays() + def update_total_holidays(self): + self.total_holidays = sum(0.5 if cint(holiday.is_half_day) else 1 for holiday in self.holidays) + @frappe.whitelist() def get_weekly_off_dates(self): if not self.weekly_off: @@ -67,6 +70,8 @@ class HolidayList(Document): }, ) + self.update_total_holidays() + @frappe.whitelist() def get_supported_countries(self): from holidays.utils import list_supported_countries @@ -108,6 +113,8 @@ class HolidayList(Document): "holidays", {"description": holiday_name, "holiday_date": holiday_date, "weekly_off": 0} ) + self.update_total_holidays() + def sort_holidays(self): self.holidays.sort(key=lambda x: getdate(x.holiday_date)) for i in range(len(self.holidays)): @@ -153,6 +160,7 @@ class HolidayList(Document): @frappe.whitelist() def clear_table(self): self.set("holidays", []) + self.update_total_holidays() def validate_duplicate_date(self): unique_dates = [] diff --git a/erpnext/setup/doctype/holiday_list/test_holiday_list.py b/erpnext/setup/doctype/holiday_list/test_holiday_list.py index 342918b8d64..91924894372 100644 --- a/erpnext/setup/doctype/holiday_list/test_holiday_list.py +++ b/erpnext/setup/doctype/holiday_list/test_holiday_list.py @@ -5,7 +5,7 @@ from contextlib import contextmanager from datetime import date, timedelta import frappe -from frappe.utils import getdate +from frappe.utils import get_datetime, getdate from erpnext.setup.doctype.holiday_list.holiday_list import local_country_name from erpnext.tests.utils import ERPNextTestSuite @@ -46,6 +46,94 @@ class TestHolidayList(ERPNextTestSuite): self.assertIn(date(2023, 2, 26), holidays) self.assertNotIn(date(2023, 3, 5), holidays) + def test_total_holidays_includes_half_days(self): + holiday_list = make_holiday_list( + "test_half_day_holiday_list", + from_date="2023-01-01", + to_date="2023-01-03", + holiday_dates=[ + {"holiday_date": "2023-01-01", "description": "Full-day holiday"}, + { + "holiday_date": "2023-01-02", + "description": "Half-day holiday", + "is_half_day": 1, + }, + ], + ) + + self.assertEqual(holiday_list.total_holidays, 1.5) + self.assertEqual(frappe.db.get_value("Holiday List", holiday_list.name, "total_holidays"), 1.5) + + def test_weekly_off_updates_total_without_saving(self): + holiday_list = frappe.new_doc("Holiday List") + holiday_list.from_date = "2023-01-01" + holiday_list.to_date = "2023-01-14" + holiday_list.weekly_off = "Saturday" + holiday_list.is_half_day = 1 + holiday_list.append("holidays", {"holiday_date": "2023-01-01", "description": "Full day"}) + + holiday_list.get_weekly_off_dates() + self.assertEqual(len(holiday_list.holidays), 3) + self.assertEqual(holiday_list.total_holidays, 2) + + holiday_list.get_weekly_off_dates() + self.assertEqual(len(holiday_list.holidays), 3) + self.assertEqual(holiday_list.total_holidays, 2) + + holiday_list.clear_table() + self.assertEqual(holiday_list.holidays, []) + self.assertEqual(holiday_list.total_holidays, 0) + + def test_local_holidays_updates_total_without_saving(self): + holiday_list = frappe.new_doc("Holiday List") + holiday_list.from_date = "2023-01-01" + holiday_list.to_date = "2023-01-02" + holiday_list.country = "DE" + holiday_list.append( + "holidays", {"holiday_date": "2023-01-02", "description": "Half day", "is_half_day": 1} + ) + + holiday_list.get_local_holidays() + self.assertEqual(len(holiday_list.holidays), 2) + self.assertEqual(holiday_list.total_holidays, 1.5) + + holiday_list.get_local_holidays() + self.assertEqual(len(holiday_list.holidays), 2) + self.assertEqual(holiday_list.total_holidays, 1.5) + + def test_recalculate_existing_holiday_list_totals(self): + from erpnext.patches.v16_0.recalculate_holiday_list_totals import execute + + cases = (("mixed", [0, 1], 1.5), ("half", [1, 1, 1], 1.5), ("full", [0, 0], 2), ("empty", [], 0)) + holiday_lists = [] + for name, half_days, expected in cases: + holiday_list = make_holiday_list( + f"test_backfill_holidays_{name}", + from_date="2023-01-01", + to_date="2023-01-03", + holiday_dates=[ + { + "holiday_date": date(2023, 1, idx), + "description": "Test holiday", + "is_half_day": is_half_day, + } + for idx, is_half_day in enumerate(half_days, start=1) + ], + ) + # Simulate totals persisted by the old controller, including a stale empty list. + holiday_list.db_set("total_holidays", len(half_days) or 1, update_modified=False) + holiday_lists.append((holiday_list, expected)) + + for _ in range(2): + execute() + for holiday_list, expected in holiday_lists: + with self.subTest(holiday_list=holiday_list.name): + total, modified = frappe.db.get_value( + "Holiday List", holiday_list.name, ["total_holidays", "modified"] + ) + self.assertEqual(total, expected) + self.assertEqual(modified, get_datetime(holiday_list.modified)) + def test_local_holidays(self): holiday_list = frappe.new_doc("Holiday List") holiday_list.from_date = "2022-01-01"