From d0fe15347052eecc6261fa2b05b484a1cf5ff39e Mon Sep 17 00:00:00 2001 From: Corentin Forler <8860073-cforler_dokos@users.noreply.gitlab.com> Date: Mon, 23 Oct 2023 16:57:26 +0200 Subject: [PATCH 1/2] fix(Item Booking): Cancel Booking Credit Usage on cancel --- .../venue/doctype/item_booking/item_booking.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/erpnext/venue/doctype/item_booking/item_booking.py b/erpnext/venue/doctype/item_booking/item_booking.py index 8e4eff106c..6cdf41b64e 100644 --- a/erpnext/venue/doctype/item_booking/item_booking.py +++ b/erpnext/venue/doctype/item_booking/item_booking.py @@ -131,7 +131,10 @@ class ExceptionBookingOverlap(BookingException): class ExceptionTooManyBookings(ExceptionBookingOverlap): @classmethod def throw_website(cls, doc: "ItemBooking", overlaps: list): - cls.throw_desk(doc, overlaps) + frappe.throw( + _("The maximum number of simultaneous bookings allowed for this item has been reached."), + exc=cls, + ) @classmethod def throw_desk(cls, doc: "ItemBooking", overlaps: list): @@ -393,6 +396,16 @@ class ItemBooking(Document): def on_update(self): self.synchronize_child_bookings() + if self.status == "Cancelled": + for bcu in frappe.get_all( + "Booking Credit Usage", + filters={"item_booking": self.name}, + pluck="name", + ): + bcu_doc = frappe.get_doc("Booking Credit Usage", bcu) + bcu_doc.flags.ignore_permissions = True + bcu_doc.cancel() + def synchronize_child_bookings(self): def update_child(item, childname=None): child = ( -- GitLab From 40b73dff9bab2be5c7cc7fbdbc4573911a282a4a Mon Sep 17 00:00:00 2001 From: Corentin Forler <8860073-cforler_dokos@users.noreply.gitlab.com> Date: Mon, 23 Oct 2023 16:58:43 +0200 Subject: [PATCH 2/2] wip --- erpnext/templates/pages/cart.py | 12 + .../doctype/item_booking/item_booking.py | 300 +++++++++++++----- 2 files changed, 233 insertions(+), 79 deletions(-) diff --git a/erpnext/templates/pages/cart.py b/erpnext/templates/pages/cart.py index bc61feb28a..e2ac3d02e7 100644 --- a/erpnext/templates/pages/cart.py +++ b/erpnext/templates/pages/cart.py @@ -18,3 +18,15 @@ def get_context(context): @frappe.whitelist(allow_guest=True) def get_availabilities_for_cart(item, start, end, uom=None): return get_availabilities(item, start, end, uom) + + quotation = get_cart_quotation() + + booked_items = [item.item_booking for item in quotation.get("doc", {}).get("items")] + availabilities = get_availabilities(item, start, end, uom) + + output = [] + for availability in availabilities: + # if not availability.get("number") or availability.get("id") in booked_items: + output.append(availability) + + return output diff --git a/erpnext/venue/doctype/item_booking/item_booking.py b/erpnext/venue/doctype/item_booking/item_booking.py index 6cdf41b64e..b20b7ea123 100644 --- a/erpnext/venue/doctype/item_booking/item_booking.py +++ b/erpnext/venue/doctype/item_booking/item_booking.py @@ -4,6 +4,7 @@ import calendar import datetime import json +from collections import defaultdict from datetime import timedelta from functools import cache, cached_property from typing import TYPE_CHECKING @@ -815,23 +816,23 @@ class ItemBookingAvailabilities: return [] output = [] + item_calendar = get_item_calendar(self.item, self.uom) + all_events = _get_events(self.init, self.finish, item=self.item_doc) for dt in daterange_including_start(self.init, self.finish): # For each day, get the available slots - calendar_availability = self._check_availability(dt) + calendar_availability = self._check_availability(dt, item_calendar, all_events) if calendar_availability: output.extend(calendar_availability) return output - def _check_availability(self, date): - date = getdate(date) + def _check_availability(self, date, item_calendar, all_events=None): # type: ignore + date: datetime.date = getdate(date) # type: ignore day = calendar.day_name[date.weekday()] - item_calendar = get_item_calendar(self.item, self.uom) - availability = [] schedules = [] - dt_now = get_datetime(now()) + dt_now = now_datetime() if item_calendar.get("calendar"): schedule_for_the_day = filter(lambda x: x.day == day, item_calendar.get("calendar")) for line in schedule_for_the_day: @@ -849,7 +850,11 @@ class ItemBookingAvailabilities: schedules.append({"start": start, "end": day_end}) if schedules: - availability.extend(self._get_availability_from_schedule(schedules)) + date_start = datetime.datetime.combine(date, datetime.time.min) + date_end = datetime.datetime.combine(date, datetime.time.max) + date_all_events = all_events or _get_events(date_start, date_end, item=self.item_doc) + # All the events are passed down, to reduce SQL queries + availability.extend(self._get_availability_from_schedule(schedules, date_all_events)) return availability @@ -873,25 +878,31 @@ class ItemBookingAvailabilities: new_dt = slot_start + datetime.timedelta(minutes=rounded_offset_in_minutes) return new_dt - def _get_availability_from_schedule(self, schedules): + def _get_availability_from_schedule(self, schedules, all_events=None): available_slots = [] for line in schedules: line = frappe._dict(line) - booked_items = _get_events(line.get("start"), line.get("end"), item=self.item_doc) + line_start: datetime.datetime = line.get("start") # type: ignore + line_end: datetime.datetime = line.get("end") # type: ignore + + # All the events are passed down, to reduce SQL queries + line_events = all_events or _get_events(line_start, line_end, item=self.item_doc) + scheduled_items = [] - for event in booked_items: + for event in line_events: # Only keep booked events that overlap the schedule slot - if get_datetime(event.get("starts_on")) < line.get("end") and get_datetime( - event.get("ends_on") - ) > line.get("start"): + starts_on: datetime.datetime = get_datetime(event.get("starts_on")) # type: ignore + ends_on: datetime.datetime = get_datetime(event.get("ends_on")) # type: ignore + if starts_on < line_end and ends_on > line_start: scheduled_items.append(event) slots = self._find_available_slot(line, scheduled_items) - available_slots_ids = [s.get("id") for s in available_slots] + available_slots = slots - for slot in slots: - if slot.get("id") not in available_slots_ids: - available_slots.append(slot) + # available_slots_ids = [s.get("id") for s in available_slots] + # for slot in slots: + # if slot.get("id") not in available_slots_ids: + # available_slots.append(slot) return available_slots @@ -905,8 +916,8 @@ class ItemBookingAvailabilities: user_scheduled_items = [x for x in scheduled_items if x.get("user") == self.user] simultaneous_booking_allowed = self.simultaneous_booking_allowed - if simultaneous_booking_allowed: - scheduled_items = self.check_simultaneaous_bookings(scheduled_items) + # if simultaneous_booking_allowed: + # scheduled_items = self.check_simultaneaous_bookings(scheduled_items) slots.extend( self._get_all_slots( @@ -938,83 +949,213 @@ class ItemBookingAvailabilities: and out.get("end") == scheduled_item.get("ends_on").isoformat() ): out.id = scheduled_item.get("name") + status = "confirmed" if scheduled_item.get("status") == "Confirmed" else "selected" out.status = status out.number += 1 added = True - # if getdate(out.get("start")) == getdate(scheduled_item.get("starts_on")) or getdate(out.get("end")) == getdate(scheduled_item.get("ends_on")): - # out.color = "red" + elif getdate(out.get("start")) == getdate(scheduled_item.get("starts_on")) or getdate( + out.get("end") + ) == getdate(scheduled_item.get("ends_on")): + out.color = "red" if not added: + status = "confirmed" if scheduled_item.get("status") == "Confirmed" else "selected" out = self.get_available_dict(scheduled_item, status) - # out.color = "green" out.number += 1 output.append(out) return output - def check_simultaneaous_bookings(self, scheduled_items): - import itertools - from operator import itemgetter + # def check_simultaneaous_bookings(self, scheduled_items): + # import itertools + # from operator import itemgetter - simultaneous_bookings = self.item_doc.get("simultaneous_bookings_allowed") - if simultaneous_bookings > 1: - sorted_schedule = sorted(scheduled_items, key=itemgetter("starts_on")) - for dummy, group in itertools.groupby(sorted_schedule, key=lambda x: x["starts_on"]): - grouped_sch = [x.get("name") for x in list(group)] - if len(grouped_sch) == simultaneous_bookings: - scheduled_items = [x for x in scheduled_items if x.get("name") not in grouped_sch[:-1]] - elif len(grouped_sch) < simultaneous_bookings: - scheduled_items = [x for x in scheduled_items if x.get("name") not in grouped_sch] + # simultaneous_bookings = self.item_doc.get("simultaneous_bookings_allowed") + # if simultaneous_bookings > 1: + # sorted_schedule = sorted(scheduled_items, key=itemgetter("starts_on")) + # for dummy, group in itertools.groupby(sorted_schedule, key=lambda x: x["starts_on"]): + # grouped_sch = [x.get("name") for x in list(group)] + # if len(grouped_sch) == simultaneous_bookings: + # scheduled_items = [x for x in scheduled_items if x.get("name") not in grouped_sch[:-1]] + # elif len(grouped_sch) < simultaneous_bookings: + # scheduled_items = [x for x in scheduled_items if x.get("name") not in grouped_sch] - return scheduled_items + # return scheduled_items + + @cached_property + def duration_as_interval(self): + interval = int(datetime.timedelta(minutes=cint(self.duration)).total_seconds() / 60) + interval = datetime.timedelta(minutes=interval) + return interval def _get_all_slots(self, line, simultaneous_booking_allowed, scheduled_items=None): line_start = get_datetime(line.get("start")) line_end = get_datetime(line.get("end")) + interval = self.duration_as_interval - interval = int(datetime.timedelta(minutes=cint(self.duration)).total_seconds() / 60) - slots = sorted([(line_start, line_start)] + [(line_end, line_end)]) - if not scheduled_items: - scheduled_items = [] + if not line_start or not line_end: + return [] + + exclusions = [] + if scheduled_items: + for scheduled_item in scheduled_items: + booking_start = get_datetime(scheduled_item.get("starts_on")) + booking_end = get_datetime(scheduled_item.get("ends_on")) + qty = scheduled_item.get("qty", 1) or 1 + exclusions.append((booking_start, booking_end, qty)) + + class Unavailability: + def __init__(self, dt, qty): + self.dt = dt + self.qty = qty + + def __repr__(self): + return f"" + + if exclusions: + exclusion_map: dict[datetime.datetime, int] = defaultdict(int) + exclusion_map[line_start] = 0 + exclusion_map[line_end] = 0 + for exclusion in exclusions: + start = max(exclusion[0], line_start) + end = min(exclusion[1], line_end) + qty = exclusion[2] + exclusion_map[start] += qty + exclusion_map[end] -= qty + + # Convert exclusion map to running sum + running_sum = 0 + for dt in sorted(exclusion_map.keys()): + running_sum += exclusion_map[dt] + exclusion_map[dt] = running_sum + + unavailabilities = sorted( + (Unavailability(dt, qty) for dt, qty in exclusion_map.items()), key=lambda x: x.dt + ) + else: + unavailabilities = [] - if simultaneous_booking_allowed: - vanilla_start_times = [] - for start, end in ((slots[i][0], slots[i + 1][0]) for i in range(len(slots) - 1)): - while start + timedelta(minutes=interval) <= end: - vanilla_start_times.append(start) - start += timedelta(minutes=interval) - - current_schedule = [] - for scheduled_item in scheduled_items: - sch_start = get_datetime(scheduled_item.get("starts_on")) - sch_end = get_datetime(scheduled_item.get("ends_on")) - try: - if sch_start < line_start: - # Ok, but the scheduled item begins before the current slot, then trim it. - current_schedule.append((line_start, sch_end)) - elif sch_start < line_end: - # Ok, the scheduled item ends before the end of the slot, keep it. - current_schedule.append((sch_start, sch_end)) - except Exception: - frappe.log_error(_("Slot availability error")) - - if current_schedule: - sorted_schedule = list(reduced(sorted(current_schedule, key=lambda x: x[0]))) - slots = sorted([(line_start, line_start)] + sorted_schedule + [(line_end, line_end)]) - - free_slots = [] - for start, end in ((slots[i][1], slots[i + 1][0]) for i in range(len(slots) - 1)): - while start + timedelta(minutes=interval) <= end: - if simultaneous_booking_allowed: - if start not in vanilla_start_times: - vanilla_start = [x for x in vanilla_start_times if start + timedelta(minutes=interval) <= x] - if vanilla_start: - start = vanilla_start[0] - free_slots.append({"starts_on": start, "ends_on": start + timedelta(minutes=interval)}) - start += timedelta(minutes=interval) - - return free_slots + # if not unavailabilities: + # unavailabilities.append(Unavailability(line_start, 0)) + + unav_len = len(unavailabilities) + simultaneous_bookings = self.item_doc.get("simultaneous_bookings_allowed") + max_unav_allowed = simultaneous_bookings if simultaneous_booking_allowed else 0 + + def advance_to_next_slot_with_enough_space(start_idx): + """Returns the index of the next 'unav' with qty < max_unav_allowed, or a value >= unav_len if there is no such 'unav'""" + idx = start_idx + while idx < unav_len and unavailabilities[idx].qty >= max_unav_allowed: + idx += 1 + return idx + + def get_max_qty_between(start_dt, end_dt, unav_idx=0): + max_qty = -1 + for unav in unavailabilities[unav_idx:]: + if unav.dt >= end_dt: + break + if unav.dt >= start_dt: + max_qty = max(max_qty, unav.qty) + return max_qty + + def generate_slots(): + print(unavailabilities) + unav_idx = 0 + curr_time = line_start + _infloop = 0 + while curr_time < line_end and unav_idx < unav_len: + _infloop += 1 + if _infloop > 100: + print(" * infinite loop detected") + break + + u = unavailabilities[unav_idx] if unav_idx < unav_len else None + un = unavailabilities[unav_idx + 1] if unav_idx + 1 < unav_len else None + print( + " - time is:", + curr_time, + "and qty is:", + u.qty if u else "-", + "from", + u.dt if u else "?", + "to", + un.dt if un else "forever", + ) + + unav_qty = get_max_qty_between(curr_time, curr_time + interval, unav_idx) + if unav_qty >= max_unav_allowed: + unav_idx = advance_to_next_slot_with_enough_space(unav_idx + 1) + u_next = unavailabilities[unav_idx] if unav_idx < unav_len else None + + if u_next and curr_time < u_next.dt: + # Earliest time when the next slot is available + curr_time = u_next.dt + print(" * earliest time is:", curr_time, "with qty:", u_next.qty) + print( + " * time is:", curr_time, "; and qty will be", unavailabilities[unav_idx - 1].qty, "forever" + ) + print( + " + time is:", + curr_time, + "; and qty is:", + unavailabilities[unav_idx - 1].qty if unav_idx - 1 >= 0 else "-", + "until", + u_next.dt if u_next else "forever", + ) + continue + + remaining = 1 + max_unav_allowed - (unav_qty if unav_qty > 0 else 0) + print(curr_time, curr_time + interval, line_end) + end = min(curr_time + interval, line_end) + yield curr_time, end, remaining + curr_time = end + + for start, end, remaining in generate_slots(): + yield {"starts_on": start, "ends_on": end, "remaining": remaining} + return + + # slots = sorted([(line_start, line_start)] + [(line_end, line_end)]) + # if not scheduled_items: + # scheduled_items = [] + + # if simultaneous_booking_allowed: + # vanilla_start_times = [] + # for start, end in ((slots[i][0], slots[i + 1][0]) for i in range(len(slots) - 1)): + # while start + timedelta(minutes=interval) <= end: + # vanilla_start_times.append(start) + # start += timedelta(minutes=interval) + + # current_schedule = [] + # for scheduled_item in scheduled_items: + # sch_start = get_datetime(scheduled_item.get("starts_on")) + # sch_end = get_datetime(scheduled_item.get("ends_on")) + # try: + # if sch_start < line_start: + # # Ok, but the scheduled item begins before the current slot, then trim it. + # current_schedule.append((line_start, sch_end)) + # elif sch_start < line_end: + # # Ok, the scheduled item ends before the end of the slot, keep it. + # current_schedule.append((sch_start, sch_end)) + # except Exception: + # frappe.log_error(_("Slot availability error")) + + # if current_schedule: + # sorted_schedule = list(reduced(sorted(current_schedule, key=lambda x: x[0]))) + # slots = sorted([(line_start, line_start)] + sorted_schedule + [(line_end, line_end)]) + + # free_slots = [] + # for start, end in ((slots[i][1], slots[i + 1][0]) for i in range(len(slots) - 1)): + # while start + timedelta(minutes=interval) <= end: + # if simultaneous_booking_allowed: + # if start not in vanilla_start_times: + # vanilla_start = [x for x in vanilla_start_times if start + timedelta(minutes=interval) <= x] + # if vanilla_start: + # start = vanilla_start[0] + # free_slots.append({"starts_on": start, "ends_on": start + timedelta(minutes=interval)}) + # start += timedelta(minutes=interval) + + # return free_slots def get_available_dict(self, slot, status=None): """ @@ -1029,10 +1170,11 @@ class ItemBookingAvailabilities: "id": slot.get("name") or frappe.generate_hash(length=8), "status": status or "available", "number": 0, + "remaining": slot.get("remaining", 0), "total_available": self.item_doc.get("simultaneous_bookings_allowed"), "display": "background", "color": None, - "allDay": 1, + "allDay": 0, } ) @@ -1353,8 +1495,8 @@ def daterange(start_date, end_date): def daterange_including_start(start_date, end_date): - if start_date < get_datetime(now()): - start_date = datetime.datetime.now() + # if start_date < get_datetime(now()): + # start_date = datetime.datetime.now() start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0) for n in range(int((end_date - start_date).days)): yield start_date + timedelta(n) -- GitLab