Search code examples
pythondatetimetimedelta

Calculate required start date for work order excluding weekends to reach shipping date


Suppose there is an order which we have to ship on 28/04/2023 12:00 PM and to finish that order we need 11d:03h:36m:10s time. Find datetime when we should start working on that order.

Conditions:

  1. Planned start date should not fall in Friday 22:00 PM to Sunday 22:00 PM.
  2. Also exclude Friday 22:00 PM to Sunday 22:00 PM if in Start and Ship date range.

In short we don't have to consider Friday 22:00 PM to Sunday 22:00 PM at any cost.

I tried with this code. It uses an initial guess for the start date by subtracting the required time of the work order from the shipping date and then tries to adjust it by taking weekends into account:

import datetime
from datetime import timedelta, time
import pytz

excluded_start_time = time(22, 0)
excluded_end_time = time(22, 0)
excluded_days = {4, 5, 6}

# straight subtraction of ship date and operation time.
# no friday and sunday logic applied
start_date = datetime.datetime(2023, 4, 17, 8, 23, 50) 

end_date = datetime.datetime(2023, 4, 28, 12, 0, 0)

def count_weekends(start_date, end_date):
    while end_date.date() > start_date.date() or (start_date.weekday() in excluded_days and excluded_start_time <= start_date <=excluded_end_time):
        friday = datetime.datetime.combine(start_date + timedelta(days=(4-start_date.weekday())), datetime.time(hour=22))
        sunday = datetime.datetime.combine(start_date + timedelta(days=(6-start_date.weekday())), datetime.time(hour=22))
        if friday <= end_date <= sunday:
            start_date = start_date - timedelta(hours=48)
            
        end_date = end_date - timedelta(hours=24)
    return start_date

print(count_weekends(start_date, end_date))       

But I got incorrect results, it returns 11/04/2023 08:23 AM, the correct answer should be 13/04/2023 8:23 AM.


Solution

  • from datetime import datetime, timedelta, time
    
    # Constants for weekdays
    MONDAY = 0
    TUESDAY = 1
    WEDNESDAY = 2
    THURSDAY = 3
    FRIDAY = 4
    SATURDAY = 5
    SUNDAY = 6
    
    NON_WORKING_START_TIME = time(22, 0)
    NON_WORKING_END_TIME = time(22, 0)
    
    
    def is_within_non_working_period(dt: datetime) -> bool:
        """Checks if the date is within the non-working period."""
        return any([dt.weekday() == FRIDAY and dt.time() >= NON_WORKING_START_TIME,
                    dt.weekday() == SATURDAY,
                    dt.weekday() == SUNDAY and dt.time() < NON_WORKING_END_TIME])
    
    
    def shift_to_working_date(dt: datetime) -> datetime:
        """Shifts the date to the nearest working date."""
        if is_within_non_working_period(dt):
            days_to_shift = dt.weekday() - FRIDAY
            return datetime.combine(dt, NON_WORKING_START_TIME) - timedelta(days=days_to_shift, seconds=1)
        return dt
    
    
    def calculate_start_date(ship_date: datetime, required_time: timedelta) -> datetime:
        """Calculates the start date for the order."""
        ship_date = shift_to_working_date(ship_date)
        start_date = ship_date - required_time
    
        working_time = count_working_time(start_date, ship_date)
        while working_time < required_time:
            # Add more time to the start date to meet the required time
            start_date -= required_time - working_time
    
            # Shift the start date to the nearest allowed date
            start_date = shift_to_working_date(start_date)
    
            # Recalculate the working time for the next iteration
            working_time = count_working_time(start_date, ship_date)
    
        return start_date
    
    
    def next_non_working_date(dt: datetime) -> datetime:
        """Returns the next non-working date.
    
        >>> next_non_working_date(datetime(2021, 4, 30, 23, 0))  # Sunday 23:00
        datetime.datetime(2021, 5, 5, 22, 0)  # next Friday 22:00
        """
        if dt.weekday() == SUNDAY and dt.time() >= NON_WORKING_END_TIME:
            return datetime.combine(dt, NON_WORKING_START_TIME) + timedelta(days=5)
        elif dt.weekday() < FRIDAY or dt.weekday() == FRIDAY and dt.time() < NON_WORKING_START_TIME:
            days_to_shift = FRIDAY - dt.weekday()
            return datetime.combine(dt, NON_WORKING_START_TIME) + timedelta(days=days_to_shift)
    
        # The date is within the non-working period
        return dt
    
    
    def next_working_date(dt: datetime) -> datetime:
        """Returns the next working date.
    
        >>> next_working_date(datetime(2023, 4, 30, 21, 0))  # Sunday 21:00
        datetime.datetime(2023, 4, 30, 22, 0)  # Sunday 22:00
        """
        if is_within_non_working_period(dt):
            days_to_shift = SUNDAY - dt.weekday()
            return datetime.combine(dt, NON_WORKING_END_TIME) + timedelta(days=days_to_shift)
    
        # The date is within the working period
        return dt
    
    
    def split_working_ranges(start_date: datetime, end_date: datetime) -> list[tuple[datetime, datetime]]:
        """Splits the date range into working ranges."""
        assert start_date <= end_date
    
        working_ranges = []
    
        current_end_date = start_date
    
        while current_end_date < end_date:
            current_start_date = next_working_date(current_end_date)
            current_end_date = next_non_working_date(current_start_date)
            current_end_date = min(current_end_date, end_date)
            if current_start_date < current_end_date:
                working_ranges.append((current_start_date, current_end_date))
    
        return working_ranges
    
    
    def count_working_time(start_date: datetime, end_date: datetime) -> timedelta:
        working_ranges = split_working_ranges(start_date, end_date)
        return sum([end - start for start, end in working_ranges], timedelta())
    
    
    if __name__ == "__main__":
        # Ship date and required time
        ship_date_str = "28/04/2023 12:00 PM"
        required_time_str = "11:03:36:10"
    
        # Convert required time string to timedelta
        days, hours, minutes, seconds = map(int, required_time_str.split(':'))
        required_time = timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
    
        # Convert ship date string to datetime
        ship_date = datetime.strptime(ship_date_str, "%d/%m/%Y %I:%M %p")
    
        # Calculate the start date
        start_date = calculate_start_date(ship_date, required_time)
    
        total_time = ship_date - start_date
        working_time = count_working_time(start_date, ship_date)
        non_working_time = total_time - working_time
    
        print("Start working on the order at:", start_date)
        print("Total time:", total_time)
        print("Working time:", working_time)
        print("Non-working time:", non_working_time)
    

    Result:

    Start working on the order at: 2023-04-13 08:23:50
    Total time: 15 days, 3:36:10
    Working time: 11 days, 3:36:10
    Non-working time: 4 days, 0:00:00