/*
* Copyright 2015 Red Hat, Inc. and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jbpm.process.core.timer;
import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.Properties;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.drools.core.time.TimeUtils;
import org.kie.api.time.SessionClock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Default implementation of BusinessCalendar interface that is configured with properties.
* Following are supported properties:
* <ul>
* <li>business.hours.per.week - specifies number of working days per week (default 5)</li>
* <li>business.hours.per.day - specifies number of working hours per day (default 8)</li>
* <li>business.start.hour - specifies starting hour of work day (default 9AM)</li>
* <li>business.end.hour - specifies ending hour of work day (default 5PM)</li>
* <li>business.holidays - specifies holidays (see format section for details on how to configure it)</li>
* <li>business.holiday.date.format - specifies holiday date format used (default yyyy-DD-mm)</li>
* <li>business.weekend.days - specifies days of the weekend (default Saturday and Sunday)</li>
* <li>business.cal.timezone - specifies time zone to be used (if not given uses default of the system it runs on)</li>
* </ul>
*
* <b>Format</b><br/>
*
* Holidays can be given in two formats:
* <ul>
* <li>as date range separated with colon - for instance 2012-05-01:2012-05-15</li>
* <li>single day holiday - for instance 2012-05-01</li>
* </ul>
* each holiday period should be separated from next one with comma: 2012-05-01:2012-05-15,2012-12-24:2012-12-27
* <br/>
* Holiday date format must be given in pattern that is supported by <code>java.text.SimpleDateFormat</code>.<br/>
*
* Weekend days should be given as integer that corresponds to <code>java.util.Calendar</code> constants.
* <br/>
*
*/
public class BusinessCalendarImpl implements BusinessCalendar {
private static final Logger logger = LoggerFactory.getLogger(BusinessCalendarImpl.class);
private Properties businessCalendarConfiguration;
private static final long HOUR_IN_MILLIS = 60 * 60 * 1000;
private int daysPerWeek;
private int hoursInDay;
private int startHour;
private int endHour;
private String timezone;
private List<TimePeriod> holidays;
private List<Integer> weekendDays= new ArrayList<Integer>();
private SessionClock clock;
private static final Pattern SIMPLE = Pattern.compile( "([+-])?\\s*((\\d+)[Ww])?\\s*((\\d+)[Dd])?\\s*((\\d+)[Hh])?\\s*((\\d+)[Mm])?\\s*((\\d+)[Ss])?" );
private static final int SIM_WEEK = 3;
private static final int SIM_DAY = 5;
private static final int SIM_HOU = 7;
private static final int SIM_MIN = 9;
private static final int SIM_SEC = 11;
public static final String DAYS_PER_WEEK = "business.hours.per.week";
public static final String HOURS_PER_DAY = "business.hours.per.day";
public static final String START_HOUR = "business.start.hour";
public static final String END_HOUR = "business.end.hour";
// holidays are given as date range and can have more than one value separated with comma
public static final String HOLIDAYS = "business.holidays";
public static final String HOLIDAY_DATE_FORMAT = "business.holiday.date.format";
public static final String WEEKEND_DAYS = "business.weekend.days";
public static final String TIMEZONE = "business.cal.timezone";
private static final String DEFAULT_PROPERTIES_NAME = "/jbpm.business.calendar.properties";
public BusinessCalendarImpl() {
String propertiesLocation = System.getProperty("jbpm.business.calendar.properties");
if (propertiesLocation == null) {
propertiesLocation = DEFAULT_PROPERTIES_NAME;
}
businessCalendarConfiguration = new Properties();
InputStream in = this.getClass().getResourceAsStream(propertiesLocation);
if (in != null) {
try {
businessCalendarConfiguration.load(in);
} catch (IOException e) {
logger.error("Error while loading properties for business calendar", e);
}
}
init();
}
public BusinessCalendarImpl(Properties configuration) {
this.businessCalendarConfiguration = configuration;
init();
}
public BusinessCalendarImpl(Properties configuration, SessionClock clock) {
this.businessCalendarConfiguration = configuration;
this.clock = clock;
init();
}
protected void init() {
if (this.businessCalendarConfiguration == null) {
throw new IllegalArgumentException("BusinessCalendar configuration was not provided.");
}
daysPerWeek = getPropertyAsInt(DAYS_PER_WEEK, "5");
hoursInDay = getPropertyAsInt(HOURS_PER_DAY, "8");
startHour = getPropertyAsInt(START_HOUR, "9");
endHour = getPropertyAsInt(END_HOUR, "17");
holidays = parseHolidays();
parseWeekendDays();
this.timezone = businessCalendarConfiguration.getProperty(TIMEZONE);
}
protected String adoptISOFormat(String timeExpression) {
try {
Duration p = null;
if (DateTimeUtils.isPeriod(timeExpression)) {
p = Duration.parse(timeExpression);
} else if (DateTimeUtils.isNumeric(timeExpression)) {
p = Duration.of(Long.valueOf(timeExpression), ChronoUnit.MILLIS);
} else {
OffsetDateTime dateTime = OffsetDateTime.parse(timeExpression, DateTimeFormatter.ISO_DATE_TIME);
p = Duration.between(OffsetDateTime.now(), dateTime);
}
long days = p.toDays();
long hours = p.toHours() % 24;
long minutes = p.toMinutes() % 60;
long seconds = p.getSeconds() % 60;
long milis = p.toMillis() % 1000;
StringBuffer time = new StringBuffer();
if (days > 0) {
time.append(days + "d");
}
if (hours > 0) {
time.append(hours + "h");
}
if (minutes > 0) {
time.append(minutes + "m");
}
if (seconds > 0) {
time.append(seconds + "s");
}
if (milis > 0) {
time.append(milis + "ms");
}
return time.toString();
} catch (Exception e) {
return timeExpression;
}
}
public long calculateBusinessTimeAsDuration(String timeExpression) {
timeExpression = adoptISOFormat(timeExpression);
if (businessCalendarConfiguration == null) {
return TimeUtils.parseTimeString(timeExpression);
}
Date calculatedDate = calculateBusinessTimeAsDate(timeExpression);
return (calculatedDate.getTime() - getCurrentTime());
}
public Date calculateBusinessTimeAsDate(String timeExpression) {
timeExpression = adoptISOFormat(timeExpression);
if (businessCalendarConfiguration == null) {
return new Date(TimeUtils.parseTimeString(getCurrentTime() + timeExpression));
}
String trimmed = timeExpression.trim();
int weeks = 0;
int days = 0;
int hours = 0;
int min = 0;
int sec = 0;
if( trimmed.length() > 0 ) {
Matcher mat = SIMPLE.matcher( trimmed );
if ( mat.matches() ) {
weeks = (mat.group( SIM_WEEK ) != null) ? Integer.parseInt( mat.group( SIM_WEEK ) ) : 0;
days = (mat.group( SIM_DAY ) != null) ? Integer.parseInt( mat.group( SIM_DAY ) ) : 0;
hours = (mat.group( SIM_HOU ) != null) ? Integer.parseInt( mat.group( SIM_HOU ) ) : 0;
min = (mat.group( SIM_MIN ) != null) ? Integer.parseInt( mat.group( SIM_MIN ) ) : 0;
sec = (mat.group( SIM_SEC ) != null) ? Integer.parseInt( mat.group( SIM_SEC ) ) : 0;
}
}
int time = 0;
Calendar c = new GregorianCalendar();
if (timezone != null) {
c.setTimeZone(TimeZone.getTimeZone(timezone));
}
if (this.clock != null) {
c.setTimeInMillis(this.clock.getCurrentTime());
}
// calculate number of weeks
int numberOfWeeks = days/daysPerWeek + weeks;
if (numberOfWeeks > 0) {
c.add(Calendar.WEEK_OF_YEAR, numberOfWeeks);
}
handleWeekend(c);
hours += (days - (numberOfWeeks * daysPerWeek)) * hoursInDay;
// calculate number of days
int numberOfDays = hours/hoursInDay;
if (numberOfDays > 0) {
for (int i = 0; i < numberOfDays; i++) {
c.add(Calendar.DAY_OF_YEAR, 1);
handleWeekend(c);
handleHoliday(c);
}
}
int currentCalHour = c.get(Calendar.HOUR_OF_DAY);
if (currentCalHour >= endHour) {
c.add(Calendar.DAY_OF_YEAR, 1);
c.add(Calendar.HOUR_OF_DAY, startHour-currentCalHour);
} else if (currentCalHour < startHour) {
c.add(Calendar.HOUR_OF_DAY, startHour);
}
// calculate remaining hours
time = hours - (numberOfDays * hoursInDay);
c.add(Calendar.HOUR, time);
handleWeekend(c);
handleHoliday(c);
currentCalHour = c.get(Calendar.HOUR_OF_DAY);
if (currentCalHour >= endHour) {
c.add(Calendar.DAY_OF_YEAR, 1);
// set hour to the starting one
c.set(Calendar.HOUR_OF_DAY, startHour);
c.add(Calendar.HOUR_OF_DAY, currentCalHour - endHour);
} else if (currentCalHour < startHour) {
c.add(Calendar.HOUR_OF_DAY, startHour);
}
// calculate minutes
int numberOfHours = min/60;
if (numberOfHours > 0) {
c.add(Calendar.HOUR, numberOfHours);
min = min-(numberOfHours * 60);
}
c.add(Calendar.MINUTE, min);
// calculate seconds
int numberOfMinutes = sec/60;
if (numberOfMinutes > 0) {
c.add(Calendar.MINUTE, numberOfMinutes);
sec = sec-(numberOfMinutes * 60);
}
c.add(Calendar.SECOND, sec);
currentCalHour = c.get(Calendar.HOUR_OF_DAY);
if (currentCalHour >= endHour) {
c.add(Calendar.DAY_OF_YEAR, 1);
// set hour to the starting one
c.set(Calendar.HOUR_OF_DAY, startHour);
c.add(Calendar.HOUR_OF_DAY, currentCalHour - endHour);
} else if (currentCalHour < startHour) {
c.add(Calendar.HOUR_OF_DAY, startHour);
}
// take under consideration weekend
handleWeekend(c);
// take under consideration holidays
handleHoliday(c);
return c.getTime();
}
protected void handleHoliday(Calendar c) {
if (!holidays.isEmpty()) {
Date current = c.getTime();
for (TimePeriod holiday : holidays) {
// check each holiday if it overlaps current date and break after first match
if (current.after(holiday.getFrom()) && current.before(holiday.getTo())) {
Calendar tmp = new GregorianCalendar();
tmp.setTime(holiday.getTo());
Calendar tmp2 = new GregorianCalendar();
tmp2.setTime(current);
tmp2.set(Calendar.HOUR_OF_DAY, 0);
tmp2.set(Calendar.MINUTE, 0);
tmp2.set(Calendar.SECOND, 0);
tmp2.set(Calendar.MILLISECOND, 0);
long difference = tmp.getTimeInMillis() - tmp2.getTimeInMillis();
c.add(Calendar.HOUR_OF_DAY, (int) (difference/HOUR_IN_MILLIS));
handleWeekend(c);
break;
}
}
}
}
protected int getPropertyAsInt(String propertyName, String defaultValue) {
String value = businessCalendarConfiguration.getProperty(propertyName, defaultValue);
return Integer.parseInt(value);
}
protected List<TimePeriod> parseHolidays() {
String holidaysString = businessCalendarConfiguration.getProperty(HOLIDAYS);
List<TimePeriod> holidays = new ArrayList<TimePeriod>();
int currentYear = Calendar.getInstance().get(Calendar.YEAR);
if (holidaysString != null) {
String[] hPeriods = holidaysString.split(",");
SimpleDateFormat sdf = new SimpleDateFormat(businessCalendarConfiguration.getProperty(HOLIDAY_DATE_FORMAT, "yyyy-MM-dd"));
for (String hPeriod : hPeriods) {
boolean addNextYearHolidays = false;
String[] fromTo = hPeriod.split(":");
if (fromTo[0].startsWith("*")) {
addNextYearHolidays = true;
fromTo[0] = fromTo[0].replaceFirst("\\*", currentYear+"");
}
try {
if (fromTo.length == 2) {
Calendar tmpFrom = new GregorianCalendar();
if (timezone != null) {
tmpFrom.setTimeZone(TimeZone.getTimeZone(timezone));
}
tmpFrom.setTime(sdf.parse(fromTo[0]));
if (fromTo[1].startsWith("*")) {
fromTo[1] = fromTo[1].replaceFirst("\\*", currentYear+"");
}
Calendar tmpTo = new GregorianCalendar();
if (timezone != null) {
tmpTo.setTimeZone(TimeZone.getTimeZone(timezone));
}
tmpTo.setTime(sdf.parse(fromTo[1]));
Date from = tmpFrom.getTime();
tmpTo.add(Calendar.DAY_OF_YEAR, 1);
if ((tmpFrom.get(Calendar.MONTH) > tmpTo.get(Calendar.MONTH)) && (tmpFrom.get(Calendar.YEAR) == tmpTo.get(Calendar.YEAR))) {
tmpTo.add(Calendar.YEAR, 1);
}
Date to = tmpTo.getTime();
holidays.add(new TimePeriod(from, to));
holidays.add(new TimePeriod(from, to));
if (addNextYearHolidays) {
tmpFrom = new GregorianCalendar();
if (timezone != null) {
tmpFrom.setTimeZone(TimeZone.getTimeZone(timezone));
}
tmpFrom.setTime(sdf.parse(fromTo[0]));
tmpFrom.add(Calendar.YEAR, 1);
from = tmpFrom.getTime();
tmpTo = new GregorianCalendar();
if (timezone != null) {
tmpTo.setTimeZone(TimeZone.getTimeZone(timezone));
}
tmpTo.setTime(sdf.parse(fromTo[1]));
tmpTo.add(Calendar.YEAR, 1);
tmpTo.add(Calendar.DAY_OF_YEAR, 1);
if ((tmpFrom.get(Calendar.MONTH) > tmpTo.get(Calendar.MONTH)) && (tmpFrom.get(Calendar.YEAR) == tmpTo.get(Calendar.YEAR))) {
tmpTo.add(Calendar.YEAR, 1);
}
to = tmpTo.getTime();
holidays.add(new TimePeriod(from, to));
}
} else {
Calendar c = new GregorianCalendar();
c.setTime(sdf.parse(fromTo[0]));
c.add(Calendar.DAY_OF_YEAR, 1);
// handle one day holiday
holidays.add(new TimePeriod(sdf.parse(fromTo[0]), c.getTime()));
if (addNextYearHolidays) {
Calendar tmp = Calendar.getInstance();
tmp.setTime(sdf.parse(fromTo[0]));
tmp.add(Calendar.YEAR, 1);
Date from = tmp.getTime();
c.add(Calendar.YEAR, 1);
holidays.add(new TimePeriod(from, c.getTime()));
}
}
} catch (Exception e) {
logger.error("Error while parsing holiday in business calendar", e);
}
}
}
return holidays;
}
protected void parseWeekendDays() {
String weekendDays = businessCalendarConfiguration.getProperty(WEEKEND_DAYS);
if (weekendDays == null) {
this.weekendDays.add(Calendar.SATURDAY);
this.weekendDays.add(Calendar.SUNDAY);
} else {
String[] days = weekendDays.split(",");
for (String day : days) {
this.weekendDays.add(Integer.parseInt(day));
}
}
}
private class TimePeriod {
private Date from;
private Date to;
protected TimePeriod(Date from, Date to) {
this.from = from;
this.to = to;
}
protected Date getFrom() {
return this.from;
}
protected Date getTo() {
return this.to;
}
}
protected long getCurrentTime() {
if (clock != null) {
return clock.getCurrentTime();
} else {
return System.currentTimeMillis();
}
}
protected boolean isWorkingDay(int day) {
if (weekendDays.contains(day)) {
return false;
}
return true;
}
protected void handleWeekend(Calendar c) {
int dayOfTheWeek = c.get(Calendar.DAY_OF_WEEK);
while (!isWorkingDay(dayOfTheWeek)) {
c.add(Calendar.DAY_OF_YEAR, 1);
dayOfTheWeek = c.get(Calendar.DAY_OF_WEEK);
}
}
}