diff --git a/src/main/java/com/ffii/core/utils/DateUtils.java b/src/main/java/com/ffii/core/utils/DateUtils.java new file mode 100644 index 0000000..677a4d1 --- /dev/null +++ b/src/main/java/com/ffii/core/utils/DateUtils.java @@ -0,0 +1,1021 @@ +package com.ffii.core.utils; + +import java.sql.Timestamp; +import java.text.DecimalFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.time.FastDateFormat; + +/** + * Utility class for date parsing and formatting. + * + * @see FastDateFormat + * @see ISO 8601 + * @see RFC 822 + * + * @author Patrick + */ +public abstract class DateUtils extends org.apache.commons.lang3.time.DateUtils { + + /** Used to format double digit hours, minutes, and seconds */ + private static final DecimalFormat DF00 = new DecimalFormat("00"); + + /** RegEx pattern to test 24-hour 4-digit time format */ + private static final Pattern PATTERN_24HR_TIME_STRING = Pattern.compile("^(([0-1][0-9])|(2[0-3]))[0-5][0-9]$"); + + /** + * HK date formatter for date without time zone. The format used is dd/MM/yyyy. + */ + public static final FastDateFormat HK_DATE_FORMAT = FastDateFormat.getInstance("dd/MM/yyyy"); + + /** + * HK date formatter for datetime. The format used is dd/MM/yyyy HH:mm:ss. + */ + public static final FastDateFormat HK_DATETIME_FORMAT = FastDateFormat.getInstance("dd/MM/yyyy HH:mm:ss"); + + /** + * SQL date formatter for date without time zone. The format used is yyyy-MM-dd. + */ + public static final FastDateFormat SQL_DATE_FORMAT = FastDateFormat.getInstance("yyyy-MM-dd"); + + /** + * Ext JS ISO8601 formatter for date-time with time zone. The format used is yyyy-MM-dd'T'HH:mm:ssZZ. + */ + public static final FastDateFormat ISO_JS_DATETIME_TIME_ZONE_FORMAT = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSSZ"); + + /** + * Default parse patterns + *
date
object, may be {@code null}
+ *
+ * @return the formatted date string, or "" if date
is {@code null}
+ */
+ public static String formatDate(Date date) {
+ return formatDate(date, StringUtils.EMPTY);
+ }
+
+ /**
+ * Formats a Date as String using the default pattern, with a fallback default value if the date is null
+ *
+ * @param date
+ * the date
object, may be {@code null}
+ * @param defaultValue
+ * the value to return if date
is {@code null}
+ *
+ * @return the formatted date string, or the default value if date
is {@code null}
+ */
+ public static String formatDate(Date date, String defaultValue) {
+ if (date == null) return defaultValue;
+ return HK_DATE_FORMAT.format(date);
+ }
+
+ /**
+ * Formats a Date as String using the provided pattern, with a fallback default value if the date is null
+ *
+ * @param date
+ * the date
object, may be {@code null}
+ * @param pattern
+ * {@link java.text.SimpleDateFormat} compatible pattern
+ * @param defaultValue
+ * the value to return if date
is {@code null}
+ *
+ * @return the formatted date string, or the default value if date
is {@code null}
+ *
+ * @throws IllegalArgumentException
+ * if pattern is invalid
+ */
+ public static String formatDate(Date date, String pattern, String defaultValue) {
+ if (date == null) return defaultValue;
+ if (HK_DATE_FORMAT.getPattern().equals(pattern)) {
+ return HK_DATE_FORMAT.format(date);
+ } else if (SQL_DATE_FORMAT.getPattern().equals(pattern)) {
+ return SQL_DATE_FORMAT.format(date);
+ } else {
+ return FastDateFormat.getInstance(pattern).format(date);
+ }
+ }
+
+ /**
+ *
+ * Parses a string representing a date by trying a variety of different parsers.
+ *
+ *
+ * The parse will try each parse pattern in turn. A parse is only deemed successful if it parses the whole of the input string. If no parse patterns match,
+ * the default value is returned.
+ *
+ *
+ * The parser parses strictly - it does not allow for dates such as "February 942, 1996".
+ *
+ *
+ * @param str
+ * the date to parse, not null
+ * @param parsePatterns
+ * the date format patterns to use, see {@link SimpleDateFormat}, not null
+ * @param defaultValue
+ * the default value to use as fallback
+ *
+ * @return the parsed {@link Date} object
+ *
+ * @see DateUtils#parseDateStrictly(String, String[])
+ */
+ public static Date parseDateStrictly(String str, String[] parsePatterns, Date defaultValue) {
+ if (str == null) return defaultValue;
+ try {
+ return parseDateStrictly(str, parsePatterns);
+ } catch (ParseException e) {
+ return defaultValue;
+ }
+ }
+
+ /**
+ *
+ * Parses a string representing a date by trying a variety of different parsers.
+ *
+ *
+ * The parse will try each parse pattern in turn. A parse is only deemed successful if it parses the whole of the input string. If no parse patterns match,
+ * the default value is returned.
+ *
+ *
+ * The parser parses strictly - it does not allow for dates such as "February 942, 1996".
+ *
+ *
+ * @param str
+ * the date to parse, not null
+ * @param defaultValue
+ * the default value to use as fallback
+ *
+ * @return the parsed {@link Date} object
+ *
+ * @see DateUtils#parseDateStrictly(String, String[])
+ */
+ public static Date parseDateStrictly(String str, Date defaultValue) {
+ return parseDateStrictly(str, PARSE_PATTERNS, defaultValue);
+ }
+
+ /**
+ * Clone a date
+ *
+ * @return The cloned date, or null if input date is null
+ */
+ @SuppressWarnings("unchecked")
+ public static T clone(Date date) {
+ return (date == null) ? null : (T) date.clone();
+ }
+
+ /**
+ * Check if the time is valid 24-hour time
+ */
+ @Deprecated
+ public static boolean isValid24HourTime(int time) {
+ int hour = time / 100;
+ int minute = time - hour * 100;
+
+ // invalid hour
+ if (hour < 0 || hour > 23) return false;
+
+ // invalid minute
+ if (minute < 0 || minute > 59) return false;
+
+ return true;
+ }
+
+ /**
+ * Check if the time string is valid 24-hour format (4 digit without any symbols)
+ */
+ public static final boolean isValid24HourTime(String timeString) {
+ if (timeString == null || timeString.length() != 4) {
+ // if it's null or not 4-digit
+ return false;
+ } else {
+ // else check against RegEx
+ return PATTERN_24HR_TIME_STRING.matcher(timeString).matches();
+ }
+ }
+
+ /**
+ * Converts 24-hour time string to a long value in millisecond
+ *
+ * @param str
+ * 24-hour time string in the format of "hh:mm" or "hhmm"
+ */
+ public static long convertTimeStringToMillisecond(String str) {
+ if (StringUtils.isNotBlank(str)) {
+ str = StringUtils.remove(str, ':');
+ int time = NumberUtils.toInt(str);
+ int hour = time / 100;
+ int minute = time - hour * 100;
+ return hour * 3600000 + minute * 60000;
+ }
+ return 0L;
+ }
+
+ /**
+ * Returns a new Date object by adding the time value from a 24-hour time string
+ *
+ * @param clazz
+ * the Date class to return
+ * @param date
+ * the Date object that the time to add to, will not be changed
+ * @param str
+ * 24-hour time string in the format of "hh:mm" or "hhmm"
+ */
+ @SuppressWarnings("unchecked")
+ public static T addTimeToDate(Class clazz, Date date, String str) {
+ if (date != null && StringUtils.isNotBlank(str)) {
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ c.set(Calendar.HOUR_OF_DAY, 0);
+ c.set(Calendar.MINUTE, 0);
+ c.set(Calendar.SECOND, 0);
+ c.set(Calendar.MILLISECOND, 0);
+ long time = c.getTimeInMillis() + convertTimeStringToMillisecond(str);
+ if (clazz == Date.class) {
+ return (T) new Date(time);
+ } else if (clazz == java.sql.Date.class) {
+ return (T) new java.sql.Date(time);
+ } else if (clazz == Timestamp.class) {
+ return (T) new java.sql.Timestamp(time);
+ } else {
+ throw new UnsupportedOperationException(clazz.getName() + " is not supported.");
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns today with only the date component
+ */
+ public static Date getToday() {
+ return truncate(new Date(), Calendar.DAY_OF_MONTH);
+ }
+
+ /**
+ * Returns the calendar with HK Week of Year Standard
+ *
+ */
+ public static final Calendar getCalendar() {
+ Calendar calendar = Calendar.getInstance();
+ calendar.setMinimalDaysInFirstWeek(3);
+ return calendar;
+ }
+
+ /**
+ * Returns the week number within the current year (e.g. if the date is within the first week of the year, it will return 1)
+ *
+ * @param date
+ * non-{@code null} {@code date} object
+ */
+ public static final int getWeekOfYear(Date date) {
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTime(date);
+ return calendar.get(Calendar.WEEK_OF_YEAR);
+ }
+
+ /**
+ * Returns the week number within the current year (e.g. if the date is within the first week of the year, it will return 1), where you can specify the
+ * first day of the week
+ *
+ * @param date
+ * non-{@code null} {@code date} object
+ * @param firstDayOfWeek
+ * the first day of the week (e.g. Calendar.SUNDAY
, Calendar.MONDAY
, etc)
+ */
+ public static final int getWeekOfYear(Date date, int firstDayOfWeek) {
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTime(date);
+ calendar.setFirstDayOfWeek(firstDayOfWeek);
+ return calendar.get(Calendar.WEEK_OF_YEAR);
+ }
+
+ /**
+ * Returns {@code true} if the {@code date} is earlier than today (ignoring the time component), returns {@code false} if later than or same as today
+ */
+ public static boolean isEarlierThanToday(Date date) {
+ return truncate(date, Calendar.DAY_OF_MONTH).getTime() < getToday().getTime();
+ }
+
+ /**
+ * Returns {@code true} if the {@code date} is later than today (ignoring the time component), returns {@code false} if earlier than or same as today
+ */
+ public static boolean isLaterThanToday(Date date) {
+ return truncate(date, Calendar.DAY_OF_MONTH).getTime() > getToday().getTime();
+ }
+
+ /**
+ * Returns {@code true} if date {@code a} is earlier than date {@code b} (ignoring the time component), returns {@code false} if later than or same day
+ */
+ public static boolean isEarlierThan(Date a, Date b) {
+ return truncate(a, Calendar.DAY_OF_MONTH).getTime() < truncate(b, Calendar.DAY_OF_MONTH).getTime();
+ }
+
+ /**
+ * Returns {@code true} if date {@code a} is later than date {@code b} (ignoring the time component), returns {@code false} if earlier than or same day
+ */
+ public static boolean isLaterThan(Date a, Date b) {
+ return truncate(a, Calendar.DAY_OF_MONTH).getTime() > truncate(b, Calendar.DAY_OF_MONTH).getTime();
+ }
+
+ /**
+ * Returns {@code true} if date {@code a} is the same date as date {@code b} (ignoring the time component), else returns {@code false}
+ */
+ public static boolean isSameDate(Date a, Date b) {
+ return truncate(a, Calendar.DAY_OF_MONTH).getTime() == truncate(b, Calendar.DAY_OF_MONTH).getTime();
+ }
+
+ /**
+ * Calculate the difference in days of (toDate - fromDate), ignoring the time component of both dates.
+ */
+ public static long getDiffInDays(Date fromDate, Date toDate) {
+ // ignore the time component of both dates
+ Date fromDateT = truncate(fromDate, Calendar.DAY_OF_MONTH);
+ Date toDateT = truncate(toDate, Calendar.DAY_OF_MONTH);
+
+ // calculate the difference in ms divided by MILLIS_PER_DAY, thus the difference in days
+ return (toDateT.getTime() - fromDateT.getTime()) / MILLIS_PER_DAY;
+ }
+
+ /**
+ * Calculate the difference in minutes of (toDate - fromDate), by truncating up to the minute of both dates.
+ */
+ public static final long getDiffInMins(Date fromDate, Date toDate) {
+ // truncate up to the minute of both dates
+ Date fromDateT = DateUtils.truncate(fromDate, Calendar.MINUTE);
+ Date toDateT = DateUtils.truncate(toDate, Calendar.MINUTE);
+
+ return (toDateT.getTime() - fromDateT.getTime()) / DateUtils.MILLIS_PER_MINUTE;
+ }
+
+ /**
+ * Calculate time diff in mins (same day or within 1 day)
+ */
+ public static final int getDiffInMins(String timeFrom, String timeTo) {
+ int diffMins = 0;
+
+ // defaults to "0000" if empty
+ if (StringUtils.isBlank(timeFrom)) timeFrom = "0000";
+ if (StringUtils.isBlank(timeTo)) timeTo = "0000";
+
+ if (!isValid24HourTime(timeFrom) || !isValid24HourTime(timeTo)) {
+ return diffMins;
+ }
+
+ try {
+ int timeFromHour = Integer.parseInt(timeFrom.substring(0, 2));
+ int timeFromMin = Integer.parseInt(timeFrom.substring(2));
+ int timeToHour = Integer.parseInt(timeTo.substring(0, 2));
+ int timeToMin = Integer.parseInt(timeTo.substring(2));
+
+ diffMins += 60 - timeFromMin;
+ diffMins += timeToMin;
+ diffMins += ((timeToHour - 1) - timeFromHour) * 60;
+
+ if (timeToHour < timeFromHour) {
+ // may be across a 00:00
+ diffMins += 24 * 60;
+ }
+ } catch (Exception e) {
+ }
+
+ return diffMins;
+ }
+
+ public static final String get24HourTimeFromDate(Date date) {
+ if (date == null) return StringUtils.EMPTY;
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTime(date);
+ return DF00.format(calendar.get(Calendar.HOUR_OF_DAY)) + DF00.format(calendar.get(Calendar.MINUTE));
+ }
+
+ public static final String get24HourTimeFromDateWithColon(Date date) {
+ if (date == null) return StringUtils.EMPTY;
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTime(date);
+ return DF00.format(calendar.get(Calendar.HOUR_OF_DAY)) + ":" + DF00.format(calendar.get(Calendar.MINUTE));
+ }
+
+ /**
+ *
+ * Adds or subtracts the specified amount of time to the given calendar field to the {@code date} returning a new object (the original {@code date} object
+ * is unchanged), based on the calendar's rules.
+ *
+ *
+ * For example, to subtract 5 days from the {@code date}, you can achieve it by calling:
+ * {@code add(date, Calendar.DAY_OF_MONTH, -5)}.
+ *
+ *
+ * @param clazz
+ * the date class to return (supports {@link java.util.Date}, {@link java.sql.Date}, {@link java.sql.Timestamp})
+ * @param date
+ * the {@code date}, not {@code null}
+ * @param calendarField
+ * the calendar field to add to (e.g. {@code Calendar.DAY_OF_MONTH})
+ * @param amount
+ * the amount to add, may be negative
+ *
+ * @return the new {@code date} object with the amount added
+ *
+ * @throws IllegalArgumentException
+ * if the {@code date} is {@code null}
+ *
+ * @see Calendar#add(int, int)
+ */
+ @SuppressWarnings("unchecked")
+ public static T add(Class clazz, Date date, int calendarField, int amount) {
+ if (date == null) {
+ throw new IllegalArgumentException("The date must not be null");
+ }
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ c.add(calendarField, amount);
+ if (clazz == Date.class) {
+ return (T) c.getTime();
+ } else if (clazz == java.sql.Date.class) {
+ return (T) new java.sql.Date(c.getTimeInMillis());
+ } else if (clazz == Timestamp.class) {
+ return (T) new java.sql.Timestamp(c.getTimeInMillis());
+ } else {
+ throw new UnsupportedOperationException(clazz.getName() + " is not supported.");
+ }
+ }
+
+ /**
+ *
+ * Adds or subtracts the specified amount of years to the {@code date} returning a new object (the original {@code date} object is unchanged), based on the
+ * calendar's rules.
+ *
+ *
+ * @param clazz
+ * the date class to return (supports {@link java.util.Date}, {@link java.sql.Date}, {@link java.sql.Timestamp})
+ * @param date
+ * the {@code date}, not {@code null}
+ * @param amount
+ * the amount to add, may be negative
+ *
+ * @return the new {@code date} object with the amount added
+ *
+ * @throws IllegalArgumentException
+ * if the {@code date} is {@code null}
+ *
+ * @see Calendar#add(int, int)
+ */
+ public static T addYears(Class clazz, Date date, int amount) {
+ return add(clazz, date, Calendar.YEAR, amount);
+ }
+
+ /**
+ *
+ * Adds or subtracts the specified amount of months to the {@code date} returning a new object (the original {@code date} object is unchanged), based on the
+ * calendar's rules.
+ *
+ *
+ * @param clazz
+ * the date class to return (supports {@link java.util.Date}, {@link java.sql.Date}, {@link java.sql.Timestamp})
+ * @param date
+ * the {@code date}, not {@code null}
+ * @param amount
+ * the amount to add, may be negative
+ *
+ * @return the new {@code date} object with the amount added
+ *
+ * @throws IllegalArgumentException
+ * if the {@code date} is {@code null}
+ *
+ * @see Calendar#add(int, int)
+ */
+ public static T addMonths(Class clazz, Date date, int amount) {
+ return add(clazz, date, Calendar.MONTH, amount);
+ }
+
+ /**
+ *
+ * Adds or subtracts the specified amount of days to the {@code date} returning a new object (the original {@code date} object is unchanged), based on the
+ * calendar's rules.
+ *
+ *
+ * @param clazz
+ * the date class to return (supports {@link java.util.Date}, {@link java.sql.Date}, {@link java.sql.Timestamp})
+ * @param date
+ * the {@code date}, not {@code null}
+ * @param amount
+ * the amount to add, may be negative
+ *
+ * @return the new {@code date} object with the amount added
+ *
+ * @throws IllegalArgumentException
+ * if the {@code date} is {@code null}
+ *
+ * @see Calendar#add(int, int)
+ */
+ public static T addDays(Class clazz, Date date, int amount) {
+ return add(clazz, date, Calendar.DAY_OF_MONTH, amount);
+ }
+
+ /**
+ *
+ * Adds or subtracts the specified amount of hours to the {@code date} returning a new object (the original {@code date} object is unchanged), based on the
+ * calendar's rules.
+ *
+ *
+ * @param clazz
+ * the date class to return (supports {@link java.util.Date}, {@link java.sql.Date}, {@link java.sql.Timestamp})
+ * @param date
+ * the {@code date}, not {@code null}
+ * @param amount
+ * the amount to add, may be negative
+ *
+ * @return the new {@code date} object with the amount added
+ *
+ * @throws IllegalArgumentException
+ * if the {@code date} is {@code null}
+ *
+ * @see Calendar#add(int, int)
+ */
+ public static T addHours(Class clazz, Date date, int amount) {
+ return add(clazz, date, Calendar.HOUR_OF_DAY, amount);
+ }
+
+ /**
+ *
+ * Adds or subtracts the specified amount of minutes to the {@code date} returning a new object (the original {@code date} object is unchanged), based on
+ * the calendar's rules.
+ *
+ *
+ * @param clazz
+ * the date class to return (supports {@link java.util.Date}, {@link java.sql.Date}, {@link java.sql.Timestamp})
+ * @param date
+ * the {@code date}, not {@code null}
+ * @param amount
+ * the amount to add, may be negative
+ *
+ * @return the new {@code date} object with the amount added
+ *
+ * @throws IllegalArgumentException
+ * if the {@code date} is {@code null}
+ *
+ * @see Calendar#add(int, int)
+ */
+ public static T addMinutes(Class clazz, Date date, int amount) {
+ return add(clazz, date, Calendar.MINUTE, amount);
+ }
+
+ /**
+ *
+ * Adds or subtracts the specified amount of seconds to the {@code date} returning a new object (the original {@code date} object is unchanged), based on
+ * the calendar's rules.
+ *
+ *
+ * @param clazz
+ * the date class to return (supports {@link java.util.Date}, {@link java.sql.Date}, {@link java.sql.Timestamp})
+ * @param date
+ * the {@code date}, not {@code null}
+ * @param amount
+ * the amount to add, may be negative
+ *
+ * @return the new {@code date} object with the amount added
+ *
+ * @throws IllegalArgumentException
+ * if the {@code date} is {@code null}
+ *
+ * @see Calendar#add(int, int)
+ */
+ public static T addSeconds(Class clazz, Date date, int amount) {
+ return add(clazz, date, Calendar.SECOND, amount);
+ }
+
+ /**
+ * Returns the day of the month represented by this Date object.
+ *
+ * @return a value between {@code 1} and {@code 31} representing the day of the month
+ *
+ * @see Calendar#get(Calendar.DAY_OF_MONTH)
+ */
+ public static int getDay(Date date) {
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ return c.get(Calendar.DAY_OF_MONTH);
+ }
+
+ /**
+ * Returns the day of the week represented by this Date object.
+ *
+ * @return a value from {@code 1} (Sunday) to {@code 7} (Saturday) representing the day of the week
+ *
+ * @see Calendar#get(Calendar.DAY_OF_WEEK)
+ */
+ public static int getDayOfWeek(Date date) {
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ return c.get(Calendar.DAY_OF_WEEK);
+ }
+
+ /**
+ * Returns a number representing the month represented by this Date object
+ *
+ * @return a value between {@code 0} and {@code 11} representing the month, with the value {@code 0} representing January
+ *
+ * @see Calendar#get(Calendar.MONTH)
+ */
+ public static int getMonth(Date date) {
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ return c.get(Calendar.MONTH);
+ }
+
+ /**
+ * Returns the year represented by this Date object
+ *
+ * @see Calendar#get(Calendar.YEAR)
+ */
+ public static int getYear(Date date) {
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ return c.get(Calendar.YEAR);
+ }
+
+ /**
+ * Returns the number of days of the month represented by this Date object
+ *
+ * @param date
+ * non-{@code null} {@code date} object
+ */
+ public static int getNumberOfDaysOfMonth(Date date) {
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ return c.getActualMaximum(Calendar.DAY_OF_MONTH);
+ }
+
+ /**
+ * Returns the number of days by range represented by this Date object
+ *
+ * @param dateFrom
+ * Date Start From
+ * @param dateTo
+ * Date End To
+ */
+ public static int getNumberOfDaysByRange(Date dateFrom, Date dateTo) {
+ dateFrom = DateUtils.truncate(dateFrom, Calendar.DAY_OF_MONTH);
+ dateTo = DateUtils.truncate(dateTo, Calendar.DAY_OF_MONTH);
+
+ Calendar fromCal = Calendar.getInstance();
+ fromCal.setTime(dateFrom);
+
+ Calendar toCal = Calendar.getInstance();
+ toCal.setTime(dateTo);
+ int count = 0;
+ while (DateUtils.isEarlierThan(fromCal.getTime(), toCal.getTime())) {
+ count++;
+ fromCal.add(Calendar.DAY_OF_MONTH, 1);
+ }
+ return count;
+ }
+
+ /**
+ * Returns the first date of the month represented by this Date object
+ *
+ * @param date
+ * non-{@code null} {@code date} object
+ */
+ public static Date getFirstDateOfMonth(Date date) {
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTime(date);
+ calendar.set(Calendar.DAY_OF_MONTH, 1);
+ return calendar.getTime();
+ }
+
+ /**
+ * Returns the last date of the month represented by this Date object
+ *
+ * @param date
+ * non-{@code null} {@code date} object
+ */
+ public static Date getLastDateOfMonth(Date date) {
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTime(date);
+ calendar.set(Calendar.DAY_OF_MONTH, calendar.getActualMaximum(Calendar.DAY_OF_MONTH));
+ return calendar.getTime();
+ }
+
+ /**
+ * Returns the first date of the last month represented by this Date object
+ *
+ * @param date
+ * non-{@code null} {@code date} object
+ */
+ public static Date getFirstDateOfLastMonth(Date date) {
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ c.add(Calendar.MONTH, -1);
+ c.set(Calendar.DAY_OF_MONTH, 1);
+ return c.getTime();
+ }
+
+ /**
+ * Returns the last date of the last month represented by this Date object
+ *
+ * @param date
+ * non-{@code null} {@code date} object
+ */
+ public static Date getLastDateOfLastMonth(Date date) {
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ c.add(Calendar.MONTH, -1);
+ c.set(Calendar.DAY_OF_MONTH, c.getActualMaximum(Calendar.DAY_OF_MONTH));
+ return c.getTime();
+ }
+
+ /**
+ * Returns the first date of the year
+ *
+ * @param year
+ * the year
+ */
+ public static Date getFirstDateOfYear(int year) {
+ Calendar calendar = Calendar.getInstance();
+ calendar.set(year, Calendar.JANUARY, 1, 0, 0, 0);
+ return calendar.getTime();
+ }
+
+ /**
+ * Returns the last date of the year
+ *
+ * @param year
+ * the year
+ */
+ public static Date getLastDateOfYear(int year) {
+ Calendar calendar = Calendar.getInstance();
+ calendar.set(year, Calendar.DECEMBER, 31, 0, 0, 0);
+ return calendar.getTime();
+ }
+
+ /**
+ * Returns the first date of the week represented by this Date object
+ *
+ * @param date
+ * non-{@code null} {@code date} object
+ */
+ public static Date getFirstDateOfWeek(Date date) {
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ c.add(Calendar.DAY_OF_MONTH, -c.get(Calendar.DAY_OF_WEEK) + 1);
+ return c.getTime();
+ }
+
+ /**
+ * Returns the first date of the week represented by this Date object, where you can specify the first day of the week
+ *
+ * @param date
+ * non-{@code null} {@code date} object
+ * @param firstDayOfWeek
+ * the first day of the week (e.g. Calendar.SUNDAY
, Calendar.MONDAY
, etc)
+ */
+ public static Date getFirstDateOfWeek(Date date, int firstDayOfWeek) {
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ int diff = c.get(Calendar.DAY_OF_WEEK) - firstDayOfWeek;
+ if (diff < 0) diff += 7;
+ c.add(Calendar.DAY_OF_MONTH, -diff);
+ return c.getTime();
+ }
+
+ /**
+ * Returns the last date of the week represented by this Date object
+ *
+ * @param date
+ * non-{@code null} {@code date} object
+ */
+ public static Date getLastDateOfWeek(Date date) {
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ c.add(Calendar.DAY_OF_MONTH, 7 - c.get(Calendar.DAY_OF_WEEK));
+ return c.getTime();
+ }
+
+ /**
+ * Returns the last date of the week represented by this Date object, where you can specify the first day of the week
+ *
+ * @param date
+ * non-{@code null} {@code date} object
+ * @param firstDayOfWeek
+ * the first day of the week (e.g. Calendar.SUNDAY
, Calendar.MONDAY
, etc)
+ */
+ public static Date getLastDateOfWeek(Date date, int firstDayOfWeek) {
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ int diff = c.get(Calendar.DAY_OF_WEEK) - firstDayOfWeek;
+ if (diff < 0) diff += 7;
+ c.add(Calendar.DAY_OF_MONTH, 6 - diff);
+ return c.getTime();
+ }
+
+ /**
+ * Returns the first date of month by year and month
+ *
+ * @param year
+ * the year
+ * @param month
+ * a value between {@code 1} and {@code 12} representing January to December
+ */
+ public static Date getFirstDateOfMonthByYearAndMonth(int year, int month) {
+ Calendar c = Calendar.getInstance();
+ c.set(year, month - 1, 1, 0, 0, 0);
+ return c.getTime();
+ }
+
+ /**
+ * Returns the last date of month by year and month
+ *
+ * @param year
+ * the year
+ * @param month
+ * a value between {@code 1} and {@code 12} representing January to December
+ */
+ public static Date getLastDateOfMonthByYearAndMonth(int year, int month) {
+ Calendar c = Calendar.getInstance();
+ c.set(year, month - 1, 1, 0, 0, 0);
+ c.set(Calendar.DAY_OF_MONTH, c.getActualMaximum(Calendar.DAY_OF_MONTH));
+ return c.getTime();
+ }
+
+ /**
+ * Returns a new {@link java.sql.Date} object from the value of the {@link java.util.Date} object, returns {@code null} if {@code date} is null
+ *
+ * @param date
+ * {@code date} object, may be {@code null}
+ */
+ public static java.sql.Date toSqlDate(Date date) {
+ if (date == null) return null;
+ return new java.sql.Date(date.getTime());
+ }
+
+ /**
+ * Returns a new {@link java.sql.Timestamp} object from the value of the {@link java.util.Date} object, returns {@code null} if {@code date} is null
+ *
+ * @param date
+ * {@code date} object, may be {@code null}
+ */
+ public static java.sql.Timestamp toTimestamp(Date date) {
+ if (date == null) return null;
+ return new java.sql.Timestamp(date.getTime());
+ }
+
+ /**
+ * Returns the number of days in a month count by the Day of Week (Sunday to Saturday)
+ *
+ * @param year
+ * 4-digit year
+ * @param month
+ * 1 to 12 (Jan to Dec)
+ * @param weekDays
+ * Calendar.SUNDAY to Calendar.SATURDAY
+ *
+ * @return the number of days in a month count by the Day of Week
+ */
+ public static int countDaysByDayOfWeek(int year, int month, int... dayOfWeeks) {
+ Calendar c = Calendar.getInstance();
+ c.clear();
+ c.set(year, month - 1, 1); // first day of month
+
+ int dayOfWeek = c.get(Calendar.DAY_OF_WEEK);
+ int daysInMonth = c.getActualMaximum(Calendar.DAY_OF_MONTH);
+
+ int count = 0;
+ for (int day = 1; day <= daysInMonth; day++) {
+ for (int dow : dayOfWeeks) {
+ if (dayOfWeek == dow) count++;
+ }
+ dayOfWeek++;
+ if (dayOfWeek > Calendar.SATURDAY) dayOfWeek = Calendar.SUNDAY;
+ }
+ return count;
+ }
+
+ /**
+ * Returns the number of days in a month count by the Day of Week (Sunday to Saturday)
+ *
+ * @param dateFrom
+ * Date Start From
+ * @param dateTo
+ * Date End To
+ * @param weekDays
+ * Calendar.SUNDAY to Calendar.SATURDAY
+ *
+ * @return the number of days in a month count by the Day of Week
+ */
+ public static int countDaysByDayOfWeek(Date dateFrom, Date dateTo, int... dayOfWeeks) {
+ dateFrom = DateUtils.truncate(dateFrom, Calendar.DAY_OF_MONTH);
+ dateTo = DateUtils.truncate(dateTo, Calendar.DAY_OF_MONTH);
+
+ Calendar fromCal = Calendar.getInstance();
+ fromCal.setTime(dateFrom);
+
+ Calendar toCal = Calendar.getInstance();
+ toCal.setTime(dateTo);
+
+ int count = 0;
+ while (DateUtils.isEarlierThan(fromCal.getTime(), toCal.getTime())) {
+ int dayOfWeek = fromCal.get(Calendar.DAY_OF_WEEK);
+ for (int dow : dayOfWeeks) {
+ if (dayOfWeek == dow) {
+ count++;
+ break;
+ }
+ }
+ fromCal.add(Calendar.DAY_OF_MONTH, 1);
+ }
+ return count;
+ }
+
+ /**
+ * Returns {@code true} if date {@code a} is the same month and year as date {@code b} (ignoring the time component), else returns {@code false}
+ */
+ public static boolean isSameMonthYear(Date a, Date b) {
+ return getYear(a) == getYear(b) && getMonth(a) == getMonth(b);
+ }
+
+ /**
+ * Returns {@code true} if date {@code a} is the same week and year as date {@code b} (ignoring the time component), else returns {@code false}
+ */
+ public static boolean isSameWeekYear(Date a, Date b) {
+ return getYear(a) == getYear(b) && getWeekOfYear(a) == getWeekOfYear(b);
+ }
+
+ /**
+ * new Date() is deprecated
+ *
+ * @param year
+ * full year
+ * @param month
+ * 0-based
+ * @param date
+ * 1-based
+ * @return java.util.Date
+ */
+ public static Date newDate(int year, int month, int date) {
+ Calendar instance = Calendar.getInstance();
+ instance.clear();
+ instance.set(year, month, date);
+ return instance.getTime();
+ }
+
+ /**
+ * new Date() is deprecated
+ *
+ * @param year
+ * full year
+ * @param month
+ * 0-based
+ * @param date
+ * 1-based
+ * @return java.util.Date
+ */
+ public static Date newDate(int year, int month, int date, int hourOfDay, int minute) {
+ Calendar instance = Calendar.getInstance();
+ instance.clear();
+ instance.set(year, month, date, hourOfDay, minute);
+ return instance.getTime();
+ }
+
+ /**
+ * new Date() is deprecated
+ *
+ * @param year
+ * full year
+ * @param month
+ * 0-based
+ * @param date
+ * 1-based
+ * @return java.util.Date
+ */
+ public static Date newDate(int year, int month, int date, int hourOfDay, int minute, int second) {
+ Calendar instance = Calendar.getInstance();
+ instance.clear();
+ instance.set(year, month, date, hourOfDay, minute, second);
+ return instance.getTime();
+ }
+}
diff --git a/src/main/java/com/ffii/core/utils/FileUtils.java b/src/main/java/com/ffii/core/utils/FileUtils.java
new file mode 100644
index 0000000..38f94c7
--- /dev/null
+++ b/src/main/java/com/ffii/core/utils/FileUtils.java
@@ -0,0 +1,111 @@
+package com.ffii.core.utils;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * File Utils
+ *
+ * @author Patrick
+ */
+public abstract class FileUtils {
+
+ private static final Map MIMETYPES = new HashMap<>();
+
+ static {
+ MIMETYPES.put("pdf", "application/pdf");
+
+ MIMETYPES.put("doc", "application/msword");
+ MIMETYPES.put("dot", "application/msword");
+ MIMETYPES.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
+
+ MIMETYPES.put("xls", "application/vnd.ms-excel");
+ MIMETYPES.put("xlm", "application/vnd.ms-excel");
+ MIMETYPES.put("xla", "application/vnd.ms-excel");
+ MIMETYPES.put("xlc", "application/vnd.ms-excel");
+ MIMETYPES.put("xlt", "application/vnd.ms-excel");
+ MIMETYPES.put("xlw", "application/vnd.ms-excel");
+ MIMETYPES.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+
+ MIMETYPES.put("ppt", "application/vnd.ms-powerpoint");
+ MIMETYPES.put("pps", "application/vnd.ms-powerpoint");
+ MIMETYPES.put("pot", "application/vnd.ms-powerpoint");
+ MIMETYPES.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
+
+ MIMETYPES.put("bat", "application/x-msdownload");
+ MIMETYPES.put("com", "application/x-msdownload");
+ MIMETYPES.put("dll", "application/x-msdownload");
+ MIMETYPES.put("exe", "application/x-msdownload");
+ MIMETYPES.put("msi", "application/x-msdownload");
+
+ MIMETYPES.put("swf", "application/x-shockwave-flash");
+
+ MIMETYPES.put("7z", "application/x-7z-compressed");
+ MIMETYPES.put("rar", "application/x-rar-compressed");
+ MIMETYPES.put("zip", "application/zip");
+
+ MIMETYPES.put("js", "application/javascript");
+ MIMETYPES.put("json", "application/json");
+
+ MIMETYPES.put("mpga", "audio/mpeg");
+ MIMETYPES.put("mp2", "audio/mpeg");
+ MIMETYPES.put("mp2a", "audio/mpeg");
+ MIMETYPES.put("mp3", "audio/mpeg");
+ MIMETYPES.put("m2a", "audio/mpeg");
+ MIMETYPES.put("m3a", "audio/mpeg");
+
+ MIMETYPES.put("bmp", "image/bmp");
+ MIMETYPES.put("gif", "image/gif");
+ MIMETYPES.put("jpeg", "image/jpeg");
+ MIMETYPES.put("jpg", "image/jpeg");
+ MIMETYPES.put("jpe", "image/jpeg");
+ MIMETYPES.put("png", "image/png");
+ MIMETYPES.put("tiff", "image/tiff");
+ MIMETYPES.put("tif", "image/tiff");
+
+ MIMETYPES.put("css", "text/css");
+
+ MIMETYPES.put("csv", "text/csv");
+
+ MIMETYPES.put("html", "text/html");
+ MIMETYPES.put("htm", "text/html");
+
+ MIMETYPES.put("txt", "text/plain");
+ MIMETYPES.put("text", "text/plain");
+ MIMETYPES.put("conf", "text/plain");
+ MIMETYPES.put("log", "text/plain");
+
+ MIMETYPES.put("mp4", "video/mp4");
+ MIMETYPES.put("mp4v", "video/mp4");
+ MIMETYPES.put("mpg4", "video/mp4");
+
+ MIMETYPES.put("mpeg", "video/mpeg");
+ MIMETYPES.put("mpg", "video/mpeg");
+ MIMETYPES.put("mpe", "video/mpeg");
+ MIMETYPES.put("m1v", "video/mpeg");
+ MIMETYPES.put("m2v", "video/mpeg");
+
+ MIMETYPES.put("qt", "video/quicktime");
+ MIMETYPES.put("mov", "video/quicktime");
+
+ MIMETYPES.put("wmv", "video/x-ms-wmv");
+ MIMETYPES.put("wmx", "video/x-ms-wmx");
+ MIMETYPES.put("wvx", "video/x-ms-wvx");
+ MIMETYPES.put("avi", "video/x-msvideo");
+
+ // MIMETYPES.put("xxxxx", "xxxxx");
+ }
+
+ /**
+ * Guess the mimetype from the file name extension
+ *
+ * @return The mimetype guessed from the file name extension, or {@code null} if the mimetype cannot be determined
+ */
+ public static String guessMimetype(String filename) {
+ String extension = StringUtils.substringAfterLast(filename, ".");
+ String mimetype = MIMETYPES.get(extension);
+ return mimetype != null ? mimetype : "application/octet-stream";
+ }
+
+}
+
diff --git a/src/main/java/com/ffii/core/utils/NumberUtils.java b/src/main/java/com/ffii/core/utils/NumberUtils.java
new file mode 100644
index 0000000..4a2d8b3
--- /dev/null
+++ b/src/main/java/com/ffii/core/utils/NumberUtils.java
@@ -0,0 +1,461 @@
+package com.ffii.core.utils;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+import org.apache.commons.lang3.ArrayUtils;
+
+/**
+ * NumberUtils extends from Apache Commons, and incl some methods from MathUtils
+ *
+ * @author Patrick
+ */
+public abstract class NumberUtils extends org.apache.commons.lang3.math.NumberUtils {
+
+ /**
+ * Round the given value to the specified number of decimal places. The value is rounded using the given method which is any method defined in
+ * {@link BigDecimal}.
+ *
+ * @param x
+ * the value to round
+ * @param scale
+ * the number of digits to the right of the decimal point
+ * @param roundingMethod
+ * the rounding method as defined in {@link RoundingMode}
+ * @return the rounded value
+ */
+ public static double round(double x, int scale, RoundingMode roundingMethod) {
+ try {
+ return BigDecimal.valueOf(x).setScale(scale, roundingMethod).doubleValue();
+ } catch (NumberFormatException ex) {
+ if (Double.isInfinite(x)) {
+ return x;
+ } else {
+ return Double.NaN;
+ }
+ }
+ }
+
+ /**
+ * Round the given value to the specified number of decimal places. The value is rounded using the {@link RoundingMode#HALF_UP} method.
+ *
+ * @param x
+ * the value to round
+ * @param scale
+ * the number of digits to the right of the decimal point
+ * @return the rounded value
+ * @see org.apache.commons.math.util.MathUtils#round(double, int)
+ */
+ public static double round(double x, int scale) {
+ return round(x, scale, RoundingMode.HALF_UP);
+ }
+
+ /**
+ * Round up the given value to the specified number of decimal places. The value is rounded up using the {@link RoundingMode#UP} method.
+ *
+ * @param x
+ * the value to round up
+ * @param scale
+ * the number of digits to the right of the decimal point
+ * @return the rounded up value
+ */
+ public static double roundUp(double x, int scale) {
+ return round(x, scale, RoundingMode.UP);
+ }
+
+ /**
+ * Round down the given value to the specified number of decimal places. The value is rounded down using the {@link RoundingMode#DOWN} method.
+ *
+ * @param x
+ * the value to round down
+ * @param scale
+ * the number of digits to the right of the decimal point
+ * @return the rounded down value
+ */
+ public static double roundDown(double x, int scale) {
+ return round(x, scale, RoundingMode.DOWN);
+ }
+
+ /**
+ * Return the {@code int} value of an {@code Object}, or the default value if the object is either {@code null} or not an instance of {@code Number}.
+ *
+ * @param obj
+ * the {@code Object}
+ * @param defaultValue
+ * the default value
+ * @return the {@code int} value of the {@code Object}, or the default if the object is either {@code null} or not an instance of {@code Number}
+ * @see Number#intValue()
+ */
+ public static int intValue(Object obj, int defaultValue) {
+ if (obj instanceof Number) {
+ return ((Number) obj).intValue();
+ } else if (obj instanceof String) {
+ try {
+ return Integer.parseInt((String) obj);
+ } catch (NumberFormatException nfe) {
+ }
+ }
+ return defaultValue;
+ }
+
+ /**
+ * Return the {@code int} value of an {@code Object}, or {@code zero} if the object is either {@code null} or not an instance of {@code Number}.
+ *
+ * @param obj
+ * the {@code Object}
+ * @return the {@code int} value of the {@code Object}, or {@code zero} if the object is either {@code null} or not an instance of {@code Number}
+ * @see Number#intValue()
+ */
+ public static int intValue(Object obj) {
+ return intValue(obj, 0);
+ }
+
+ /**
+ * Convert an {@code Object} to an {@code Integer} (only if the object is an instance of {@code Number} or {@code String}), returning a default value if the
+ * conversion fails.
+ *
+ * If the object is {@code null}, the default value is returned.
+ *
+ * @param obj
+ * the object to convert, may be {@code null}
+ * @param defaultValue
+ * the default value, may be {@code null}
+ * @return the Integer represented by the object, or the default if conversion fails
+ */
+ public static Integer toInt(Object obj, Integer defaultValue) {
+ if (obj instanceof Number)
+ return Integer.valueOf(((Number) obj).intValue());
+ else if (obj instanceof String)
+ try {
+ return Integer.valueOf((String) obj);
+ } catch (NumberFormatException nfe) {
+ return defaultValue;
+ }
+ else
+ return defaultValue;
+ }
+
+ /**
+ * Return the {@code long} value of a {@code Long} object, or a default value if the object is {@code null}.
+ *
+ * @param obj
+ * the object (can be {@code null})
+ * @param defaultValue
+ * the default value
+ * @return the {@code long} value of the object if it's a number, or the default value if the object is {@code null} or {@code NaN}
+ * @see Long#longValue()
+ */
+ public static long longValue(Object obj, long defaultValue) {
+ return (obj instanceof Number) ? ((Number) obj).longValue() : defaultValue;
+ }
+
+ /**
+ * Return the {@code long} value of a {@code Long} object, or {@code zero} if the object is {@code null}.
+ *
+ * @param obj
+ * the object (can be {@code null})
+ * @return the {@code long} value of the object if it's a number, or {@code zero} if the object is {@code null} or {@code NaN}
+ * @see Long#longValue()
+ */
+ public static long longValue(Object obj) {
+ return longValue(obj, 0l);
+ }
+
+ /**
+ * Convert an {@code Object} to a {@code Long} (only if the object is an instance of {@code Number} or {@code String}), returning a default value if the
+ * conversion fails.
+ *
+ * If the object is {@code null}, the default value is returned.
+ *
+ * @param obj
+ * the object to convert, may be {@code null}
+ * @param defaultValue
+ * the default value, may be {@code null}
+ * @return the Long represented by the object, or the default if conversion fails
+ */
+ public static Long toLong(Object obj, Long defaultValue) {
+ if (obj instanceof Number)
+ return Long.valueOf(((Number) obj).longValue());
+ else if (obj instanceof String)
+ try {
+ return Long.valueOf((String) obj);
+ } catch (NumberFormatException nfe) {
+ return defaultValue;
+ }
+ else
+ return defaultValue;
+ }
+
+ /**
+ * @param obj
+ * the object (can be {@code null})
+ * @param defaultValue
+ * the default value
+ * @return the {@code double} value of the object if it's a number, or the default value if the object is {@code null} or {@code NaN}
+ * @see Double#doubleValue()
+ */
+ public static double doubleValue(Object obj, double defaultValue) {
+ return (obj instanceof Number) ? ((Number) obj).doubleValue() : defaultValue;
+ }
+
+ /**
+ * @param obj
+ * the object (can be {@code null})
+ * @param defaultValue
+ * the default value
+ * @return the {@code double} value of the object if it's a number, or {@code zero} if the object is {@code null} or {@code NaN}
+ * @see Double#doubleValue()
+ */
+ public static double doubleValue(Object obj) {
+ return doubleValue(obj, 0.0d);
+ }
+
+ /**
+ * Convert an {@code Object} to a {@code Double} (only if the object is an instance of {@code Number} or {@code String}), returning a default value if the
+ * conversion fails.
+ *
+ * If the object is {@code null}, the default value is returned.
+ *
+ * @param obj
+ * the object to convert, may be {@code null}
+ * @param defaultValue
+ * the default value, may be {@code null}
+ * @return the Double represented by the object, or the default if conversion fails
+ */
+ public static Double toDouble(Object obj, Double defaultValue) {
+ if (obj instanceof Number)
+ return Double.valueOf(((Number) obj).doubleValue());
+ else if (obj instanceof String)
+ try {
+ return Double.valueOf((String) obj);
+ } catch (NumberFormatException nfe) {
+ return defaultValue;
+ }
+ else
+ return defaultValue;
+ }
+
+ /**
+ * Return the {@code BigDecimal} object, or {@code zero} if the object is {@code null}.
+ *
+ * @param obj
+ * the {@code BigDecimal} object
+ * @return the {@code BigDecimal} object, or {@code zero} if the object is {@code null}
+ */
+ public static BigDecimal decimalValue(BigDecimal obj) {
+ return decimalValue(obj, BigDecimal.ZERO);
+ }
+
+ /**
+ * Return the {@code BigDecimal} object, or a default value if the object is {@code null}.
+ *
+ * @param obj
+ * the {@code BigDecimal} object
+ * @param defaultValue
+ * the default value
+ * @return the {@code BigDecimal} object, or the default if the object is {@code null}
+ */
+ public static BigDecimal decimalValue(BigDecimal obj, BigDecimal defaultValue) {
+ return obj == null ? defaultValue : obj;
+ }
+
+ /**
+ * Convert an {@code Object} to a {@code BigDecimal}, returning {@code BigDecimal.ZERO} if the conversion fails (e.g. the object is not an instance of
+ * {@code Number} nor {@code String}).
+ *
+ * If the object is {@code null}, {@code BigDecimal.ZERO} is returned.
+ *
+ * @param obj
+ * the object to convert, may be {@code null}
+ * @return the BigDecimal represented by the object, or {@code BigDecimal.ZERO} if conversion fails
+ */
+ public static BigDecimal toDecimal(Object obj) {
+ return toDecimal(obj, BigDecimal.ZERO);
+ }
+
+ /**
+ * Convert an {@code Object} to a {@code BigDecimal}, returning a default value if the conversion fails (e.g. the object is not an instance of
+ * {@code Number} nor {@code String}).
+ *
+ * If the object is {@code null}, the default value is returned.
+ *
+ * @param obj
+ * the object to convert, may be {@code null}
+ * @param defaultValue
+ * the default value, may be {@code null}
+ * @return the BigDecimal represented by the object, or the default if conversion fails
+ */
+ public static BigDecimal toDecimal(Object obj, BigDecimal defaultValue) {
+ if (obj instanceof BigDecimal)
+ return (BigDecimal) obj;
+ else if (obj instanceof Number)
+ return BigDecimal.valueOf(((Number) obj).doubleValue());
+ else if (obj instanceof String)
+ try {
+ return new BigDecimal((String) obj);
+ } catch (NumberFormatException nfe) {
+ return defaultValue;
+ }
+ else
+ return defaultValue;
+ }
+
+ /**
+ * Null-safe method to check if the two {@code Integer} objects have the same value.
+ *
+ *
+ * - Returns {@code true} if {@code a} and {@code b} are both {@code null}.
+ *
- Returns {@code false} if only one of them is {@code null}.
+ *
- Returns {@code true} if {@code a} and {@code b} are not {@code null} and have the same {@code int} value, else returns {@code false}.
+ *
+ *
+ * @param a
+ * Integer obj, may be {@code null}
+ * @param b
+ * Integer obj, may be {@code null}
+ */
+ public static boolean isEqual(Integer a, Integer b) {
+ return a == null ? (b == null ? true : false) : (b == null ? false : a.equals(b));
+ }
+
+ /**
+ * Null-safe method to check if the two {@code Integer} objects have different values.
+ *
+ *
+ * - Returns {@code false} if {@code a} and {@code b} are both {@code null}.
+ *
- Returns {@code true} if only one of them is {@code null}.
+ *
- Returns {@code true} if {@code a} and {@code b} are not {@code null} and have different {@code int} values, else returns {@code false}.
+ *
+ *
+ * @param a
+ * Integer obj, may be {@code null}
+ * @param b
+ * Integer obj, may be {@code null}
+ */
+ public static boolean isNotEqual(Integer a, Integer b) {
+ return !isEqual(a, b);
+ }
+
+ /**
+ * Null-safe method to check if the two {@code BigDecimal} objects have the same value.
+ *
+ * Two {@code BigDecimal} objects that are equal in value but have a different scale (like 2.0 and 2.00) are considered equal by this method.
+ *
+ *
+ * - Returns {@code true} if {@code a} and {@code b} are both {@code null}.
+ *
- Returns {@code false} if only one of them is {@code null}.
+ *
- Returns {@code true} if {@code a} and {@code b} are not {@code null} and have the same {@code decimal} value, else returns {@code false}.
+ *
+ *
+ * @param a
+ * BigDecimal obj, may be {@code null}
+ * @param b
+ * BigDecimal obj, may be {@code null}
+ */
+ public static boolean isEqual(BigDecimal a, BigDecimal b) {
+ return a == null ? (b == null ? true : false) : (b == null ? false : a.compareTo(b) == 0);
+ }
+
+ /**
+ * Null-safe method to check if the two {@code BigDecimal} objects have different values.
+ *
+ * Two {@code BigDecimal} objects that are equal in value but have a different scale (like 2.0 and 2.00) are considered equal by this method.
+ *
+ *
+ * - Returns {@code false} if {@code a} and {@code b} are both {@code null}.
+ *
- Returns {@code true} if only one of them is {@code null}.
+ *
- Returns {@code true} if {@code a} and {@code b} are not {@code null} and have different {@code decimal} values, else returns {@code false}.
+ *
+ *
+ * @param a
+ * BigDecimal obj, may be {@code null}
+ * @param b
+ * BigDecimal obj, may be {@code null}
+ */
+ public static boolean isNotEqual(BigDecimal a, BigDecimal b) {
+ return !isEqual(a, b);
+ }
+
+ /**
+ * Check if {@code BigDecimal} object {@code a} is greater than {@code BigDecimal} object {@code b}.
+ *
+ * @param a
+ * non-{@code null} BigDecimal obj
+ * @param b
+ * non-{@code null} BigDecimal obj
+ */
+ public static boolean isGreaterThan(BigDecimal a, BigDecimal b) {
+ return a.compareTo(b) > 0;
+ }
+
+ /**
+ * Check if {@code BigDecimal} object {@code a} is greater than or equals to {@code BigDecimal} object {@code b}.
+ *
+ * @param a
+ * non-{@code null} BigDecimal obj
+ * @param b
+ * non-{@code null} BigDecimal obj
+ */
+ public static boolean isGreaterThanOrEqual(BigDecimal a, BigDecimal b) {
+ return a.compareTo(b) >= 0;
+ }
+
+ /**
+ * Check if {@code BigDecimal} object {@code a} is less than {@code BigDecimal} object {@code b}.
+ *
+ * @param a
+ * non-{@code null} BigDecimal obj
+ * @param b
+ * non-{@code null} BigDecimal obj
+ */
+ public static boolean isLessThan(BigDecimal a, BigDecimal b) {
+ return a.compareTo(b) < 0;
+ }
+
+ /**
+ * Check if {@code BigDecimal} object {@code a} is less than or equals to {@code BigDecimal} object {@code b}.
+ *
+ * @param a
+ * non-{@code null} BigDecimal obj
+ * @param b
+ * non-{@code null} BigDecimal obj
+ */
+ public static boolean isLessThanOrEqual(BigDecimal a, BigDecimal b) {
+ return a.compareTo(b) <= 0;
+ }
+
+ /**
+ *
+ *
+ * NumberUtils.equalsAny(null, (Integer[]) null) = false
+ * NumberUtils.equalsAny(null, null, null) = true
+ * NumberUtils.equalsAny(null, 1, 2) = false
+ * NumberUtils.equalsAny(1, null, 2) = false
+ * NumberUtils.equalsAny(1, 1, 2) = true
+ *
+ *
+ * @param int
+ * to compare, may be {@code null}.
+ * @param searchInts
+ * a int, may be {@code null}.
+ * @return {@code true} if the num is equal to any other element of searchInts
; {@code false} if searchInts
is null or contains no
+ * matches.
+ */
+ public static boolean equalsAny(final int num, int... searchInts) {
+ if (ArrayUtils.isNotEmpty(searchInts)) {
+ for (int next : searchInts) {
+ if (num == next) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ public static double sum(double... nums) {
+ BigDecimal rs = BigDecimal.ZERO;
+ for (double num : nums)
+ rs = rs.add(BigDecimal.valueOf(num));
+ return rs.doubleValue();
+ }
+}
diff --git a/src/main/java/com/ffii/core/utils/StringUtils.java b/src/main/java/com/ffii/core/utils/StringUtils.java
new file mode 100644
index 0000000..454d865
--- /dev/null
+++ b/src/main/java/com/ffii/core/utils/StringUtils.java
@@ -0,0 +1,73 @@
+package com.ffii.core.utils;
+
+/**
+ * String Utils based on Apache Commons StringUtils.
+ *
+ * @author Patrick
+ */
+public abstract class StringUtils extends org.apache.commons.lang3.StringUtils {
+
+ /**
+ * The String {@code "0"}.
+ */
+ public static final String ZERO = "0";
+
+ /**
+ * The String {@code "1"}.
+ */
+ public static final String ONE = "1";
+
+ /**
+ * The String {@code "%"}.
+ */
+ public static final String PERCENT = "%";
+
+ /**
+ * The String {@code ","}.
+ */
+ public static final String COMMA = ",";
+
+ /**
+ * The String {@code "\r\n"} for line break on Windows
+ */
+ public static final String LINE_BREAK_WINDOWS = "\r\n";
+
+ /**
+ * The String {@code "\n"} for line break on Unix/Linux
+ */
+ public static final String LINE_BREAK_LINUX = "\n";
+
+ public static final String[] A2Z_LOWWER = { "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n",
+ "o", "p", "q", "r", "s", "t", "u", "v",
+ "w", "x", "y", "z" };
+ public static final String[] A2Z_UPPER = { "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
+ "O", "P", "Q", "R", "S", "T", "U", "V",
+ "W", "X", "Y", "Z" };
+
+ public static final String concat(String segregator, final String... chars) {
+ if (segregator == null)
+ segregator = "";
+ String rs = "";
+ for (String c : chars) {
+ if (c == null)
+ continue;
+ else {
+ if (StringUtils.isBlank(rs)) {
+ rs = c;
+ } else {
+ rs += segregator + c;
+ }
+ }
+ }
+ return rs;
+ }
+
+ public static final String removeLineBreak(String str) {
+ if (str == null)
+ return str;
+ return str.replace("\r\n", " ")
+ .replace("\n", " ")
+ .replace("\r", " ")
+ .trim();
+ }
+}
diff --git a/src/main/java/com/ffii/core/view/AbstractView.java b/src/main/java/com/ffii/core/view/AbstractView.java
new file mode 100644
index 0000000..6a3afbb
--- /dev/null
+++ b/src/main/java/com/ffii/core/view/AbstractView.java
@@ -0,0 +1,29 @@
+package com.ffii.core.view;
+
+public abstract class AbstractView extends org.springframework.web.servlet.view.AbstractView {
+
+ public static final String CONTENT_TYPE_JSON = "application/json";
+
+ public static final String CONTENT_TYPE_JAVASCRIPT = "text/javascript";
+
+ public static final String CONTENT_TYPE_TEXT_HTML = "text/html";
+ public static final String CONTENT_TYPE_TEXT_PLAIN = "text/plain";
+ public static final String CONTENT_TYPE_XML = "text/xml";
+
+ public static final String CONTENT_TYPE_JPEG = "image/jpeg";
+ public static final String CONTENT_TYPE_PNG = "image/png";
+
+ public static final String CHARSET_UTF8 = "UTF-8";
+
+ protected boolean disableCaching = true;
+
+ /**
+ * Disables caching of the generated JSON.
+ * Default is {@code true}, which will prevent the client from caching the generated JSON.
+ */
+ public void setDisableCaching(boolean disableCaching) {
+ this.disableCaching = disableCaching;
+ }
+
+}
+
diff --git a/src/main/java/com/ffii/tsms/modules/claim/entity/Claim.kt b/src/main/java/com/ffii/tsms/modules/claim/entity/Claim.kt
new file mode 100644
index 0000000..ead1927
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/claim/entity/Claim.kt
@@ -0,0 +1,46 @@
+package com.ffii.tsms.modules.claim.entity
+
+import com.ffii.core.entity.BaseEntity
+import com.ffii.core.entity.IdEntity
+import com.ffii.tsms.modules.data.entity.Staff
+import com.ffii.tsms.modules.file.entity.File
+import com.ffii.tsms.modules.project.entity.Project
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.JoinColumn
+import jakarta.persistence.ManyToOne
+import jakarta.persistence.OneToOne
+import jakarta.persistence.Table
+import jakarta.validation.constraints.NotNull
+import java.math.BigDecimal
+import java.time.LocalDateTime
+
+@Entity
+@Table(name = "claim")
+open class Claim : BaseEntity() {
+
+ @NotNull
+ @ManyToOne
+ @JoinColumn(name = "staffId")
+ open var staff: Staff? = null
+
+ @Column(name = "code")
+ open var code: String? = null
+
+ @NotNull
+ @Column(name = "type")
+ open var type: String? = null
+
+ @NotNull
+ @Column(name = "status")
+ open var status: String? = null
+
+ @Column(name = "verifiedDatetime")
+ open var verifiedDatetime: LocalDateTime? = null
+
+ @Column(name = "verifiedBy")
+ open var verifiedBy: Long? = null
+
+ @Column(name = "remark", length = 255)
+ open var remark: String? = null
+}
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/claim/entity/ClaimDetail.kt b/src/main/java/com/ffii/tsms/modules/claim/entity/ClaimDetail.kt
new file mode 100644
index 0000000..14a921a
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/claim/entity/ClaimDetail.kt
@@ -0,0 +1,47 @@
+package com.ffii.tsms.modules.claim.entity
+
+import com.ffii.core.entity.BaseEntity
+import com.ffii.tsms.modules.data.entity.Staff
+import com.ffii.tsms.modules.file.entity.File
+import com.ffii.tsms.modules.project.entity.Project
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.JoinColumn
+import jakarta.persistence.ManyToOne
+import jakarta.persistence.OneToOne
+import jakarta.persistence.Table
+import jakarta.validation.constraints.NotNull
+import java.math.BigDecimal
+import java.time.LocalDate
+import java.time.LocalDateTime
+
+@Entity
+@Table(name = "claim_detail")
+open class ClaimDetail : BaseEntity() {
+
+ @NotNull
+ @ManyToOne
+ @JoinColumn(name = "claimId")
+ open var claim: Claim? = null
+
+ @NotNull
+ @Column(name = "invoiceDate")
+ open var invoiceDate: LocalDate? = null
+
+ @NotNull
+ @ManyToOne
+ @JoinColumn(name = "projectId")
+ open var project: Project? = null
+
+ @NotNull
+ @Column(name = "description", length = 255)
+ open var description: String? = null
+
+ @NotNull
+ @Column(name = "amount")
+ open var amount: BigDecimal? = null
+
+ @OneToOne
+ @JoinColumn(name = "fileId")
+ open var file: File? = null
+}
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/claim/entity/ClaimDetailRepository.kt b/src/main/java/com/ffii/tsms/modules/claim/entity/ClaimDetailRepository.kt
new file mode 100644
index 0000000..3dc77d2
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/claim/entity/ClaimDetailRepository.kt
@@ -0,0 +1,7 @@
+package com.ffii.tsms.modules.claim.entity
+
+import com.ffii.core.support.AbstractRepository
+
+interface ClaimDetailRepository : AbstractRepository {
+ fun findAllByDeletedFalse(): List
+}
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/claim/entity/ClaimRepository.kt b/src/main/java/com/ffii/tsms/modules/claim/entity/ClaimRepository.kt
new file mode 100644
index 0000000..66d2654
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/claim/entity/ClaimRepository.kt
@@ -0,0 +1,9 @@
+package com.ffii.tsms.modules.claim.entity
+
+import com.ffii.core.support.AbstractRepository
+import org.springframework.data.repository.query.Param
+
+interface ClaimRepository : AbstractRepository {
+ fun findAllByDeletedFalse(): List
+ fun findAllByCodeContains(@Param("code") code:String): List
+}
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/claim/service/ClaimService.kt b/src/main/java/com/ffii/tsms/modules/claim/service/ClaimService.kt
new file mode 100644
index 0000000..3103e7b
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/claim/service/ClaimService.kt
@@ -0,0 +1,92 @@
+package com.ffii.tsms.modules.claim.service
+
+import com.ffii.tsms.modules.claim.entity.Claim
+import com.ffii.tsms.modules.claim.entity.ClaimDetail
+import com.ffii.tsms.modules.claim.entity.ClaimDetailRepository
+import com.ffii.tsms.modules.claim.entity.ClaimRepository
+import com.ffii.tsms.modules.claim.web.models.SaveClaimDetailRequest
+import com.ffii.tsms.modules.claim.web.models.SaveClaimRequest
+import com.ffii.tsms.modules.claim.web.models.SaveClaimResponse
+import com.ffii.tsms.modules.file.entity.FileBlob
+import com.ffii.tsms.modules.file.service.FileService
+import com.ffii.tsms.modules.project.entity.ProjectRepository
+import org.springframework.beans.BeanUtils
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+import java.text.NumberFormat
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import java.util.HashMap
+
+@Service
+open class ClaimService(
+ private val claimRepository: ClaimRepository,
+ private val fileService: FileService, private val claimDetailRepository: ClaimDetailRepository, private val projectRepository: ProjectRepository
+) {
+ open fun allClaims(): List {
+ return claimRepository.findAllByDeletedFalse()
+ }
+
+ open fun allClaimsByCodeContains(code: String): List {
+ return claimRepository.findAllByCodeContains(code)
+ }
+
+ open fun generateCode(): String {
+ val dateFormatter = DateTimeFormatter.ofPattern("yyMMdd")
+ val formattedToday = LocalDate.now().format(dateFormatter)
+ val claims = allClaimsByCodeContains(formattedToday)
+
+ // Format: CF-yymmddXXX
+ return "CF-" + formattedToday + String.format("%03d", (claims.size + 1))
+ }
+
+ @Transactional(rollbackFor = [Exception::class])
+ open fun saveClaimDetail(claimDetail: SaveClaimDetailRequest, claim: Claim) {
+ val oldSupportingDocumentId = claimDetail.oldSupportingDocument?.id // fileId
+ val newSupportingDocument = claimDetail.newSupportingDocument
+ val claimDetailId = claimDetail.id
+ val projectId = claimDetail.project.id
+ var result: MutableMap = HashMap()
+
+ if (oldSupportingDocumentId != null && oldSupportingDocumentId > 0 && newSupportingDocument != null) {
+ result = fileService.updateFile(multipartFile = newSupportingDocument, refId = claim.id, refType = "claimDetail", refCode = claim.code, maxSize = 50, fileId = oldSupportingDocumentId)
+ } else if ((oldSupportingDocumentId == null || oldSupportingDocumentId <= 0) && newSupportingDocument != null) {
+ result = fileService.uploadFile(multipartFile = newSupportingDocument, refId = claim.id, refType = "claimDetail", refCode = claim.code, maxSize = 50)
+ }
+
+ val instance = claimDetailRepository.findById(claimDetailId).orElse(ClaimDetail())
+ BeanUtils.copyProperties(claimDetail, instance)
+
+ val project = projectRepository.findById(projectId!!).orElseThrow()
+ instance.apply {
+ this.project = project
+ }
+
+ claimDetailRepository.save(instance)
+ }
+
+ @Transactional(rollbackFor = [Exception::class])
+ open fun saveClaim(saveClaimRequest: SaveClaimRequest): SaveClaimResponse {
+
+ val claimId = saveClaimRequest.id
+ val addClaimDetails = saveClaimRequest.addClaimDetails
+ // Save to claim
+ val instance = if (claimId != null && claimId > 0) claimRepository.findById(claimId).orElseThrow() else Claim();
+
+ instance.apply {
+ type = saveClaimRequest.expenseType
+ code = if(instance.code.isNullOrEmpty()) generateCode() else instance.code
+ }
+
+ claimRepository.save(instance)
+
+ // Save to claim detail
+ if (addClaimDetails.isNotEmpty()) {
+ for(addClaimDetail in addClaimDetails) {
+ saveClaimDetail(addClaimDetail, instance)
+ }
+ }
+
+ return SaveClaimResponse(claim = instance, message = "Success");
+ }
+}
diff --git a/src/main/java/com/ffii/tsms/modules/claim/web/ClaimController.kt b/src/main/java/com/ffii/tsms/modules/claim/web/ClaimController.kt
new file mode 100644
index 0000000..8a2c0fe
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/claim/web/ClaimController.kt
@@ -0,0 +1,29 @@
+package com.ffii.tsms.modules.claim.web
+
+import com.ffii.tsms.modules.claim.entity.Claim
+import com.ffii.tsms.modules.claim.service.ClaimService
+import com.ffii.tsms.modules.claim.web.models.SaveClaimRequest
+import com.ffii.tsms.modules.claim.web.models.SaveClaimResponse
+import com.ffii.tsms.modules.data.web.models.SaveCustomerResponse
+import com.ffii.tsms.modules.project.web.models.SaveCustomerRequest
+import jakarta.validation.Valid
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+@RequestMapping("/claim")
+class ClaimController(private val claimService: ClaimService) {
+
+ @GetMapping
+ fun allClaims(): List {
+ return claimService.allClaims()
+ }
+
+ @PostMapping("/save")
+ fun saveClaim(@Valid @RequestBody saveClaimRequest: SaveClaimRequest): SaveClaimResponse {
+ return claimService.saveClaim(saveClaimRequest)
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/claim/web/models/SaveClaimRequest.kt b/src/main/java/com/ffii/tsms/modules/claim/web/models/SaveClaimRequest.kt
new file mode 100644
index 0000000..604c58c
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/claim/web/models/SaveClaimRequest.kt
@@ -0,0 +1,36 @@
+package com.ffii.tsms.modules.claim.web.models
+
+import com.ffii.tsms.modules.claim.entity.ClaimDetail
+import jakarta.validation.constraints.NotBlank
+import org.springframework.web.multipart.MultipartFile
+import java.math.BigDecimal
+import java.time.LocalDate
+
+data class SupportingDocument (
+ val id: Long,
+ val skey: String,
+ val filename: String,
+)
+
+data class Project (
+ val id: Long,
+)
+data class SaveClaimDetailRequest (
+ val id: Long,
+ val invoiceDate: LocalDate,
+ val description: String,
+ val project: Project,
+ val amount: BigDecimal,
+ val newSupportingDocument: MultipartFile?,
+ val oldSupportingDocument: SupportingDocument?
+)
+
+data class SaveClaimRequest (
+ @field:NotBlank(message = "Expense type cannot be empty")
+ val expenseType: String,
+
+ val addClaimDetails: List,
+
+ val status: String,
+ val id: Long?,
+)
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/claim/web/models/SaveClaimResponse.kt b/src/main/java/com/ffii/tsms/modules/claim/web/models/SaveClaimResponse.kt
new file mode 100644
index 0000000..28d256c
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/claim/web/models/SaveClaimResponse.kt
@@ -0,0 +1,8 @@
+package com.ffii.tsms.modules.claim.web.models
+
+import com.ffii.tsms.modules.claim.entity.Claim
+
+data class SaveClaimResponse(
+ val claim: Claim,
+ val message: String,
+)
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/data/service/StaffsService.kt b/src/main/java/com/ffii/tsms/modules/data/service/StaffsService.kt
index b0fdcaa..00ce55d 100644
--- a/src/main/java/com/ffii/tsms/modules/data/service/StaffsService.kt
+++ b/src/main/java/com/ffii/tsms/modules/data/service/StaffsService.kt
@@ -117,7 +117,6 @@ open class StaffsService(
staffRepository.save(staff)
salaryEffectiveService.saveSalaryEffective(staff.id!!, salary.id!!)
- logger.info(staff.id)
return staff
}
@Transactional(rollbackFor = [Exception::class])
diff --git a/src/main/java/com/ffii/tsms/modules/file/entity/File.kt b/src/main/java/com/ffii/tsms/modules/file/entity/File.kt
new file mode 100644
index 0000000..882afc9
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/file/entity/File.kt
@@ -0,0 +1,39 @@
+package com.ffii.tsms.modules.file.entity
+
+import com.ffii.core.entity.BaseEntity
+import com.ffii.core.entity.IdEntity
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.Table
+import jakarta.validation.constraints.NotNull
+import java.math.BigInteger
+
+@Entity
+@Table(name = "file")
+open class File : BaseEntity() {
+
+ open val DEFAULT_UPLOAD_MAX_FILE_SIZE_MB: Int = 50
+
+ @NotNull
+ @Column(name = "skey", length = 32)
+ open var skey: String? = null
+
+ @NotNull
+ @Column(name = "filename", length = 255)
+ open var filename: String? = null
+
+ @NotNull
+ @Column(name = "extension", length = 10)
+ open var description: String? = null
+
+ @NotNull
+ @Column(name = "mimetype", length = 255)
+ open var mimetype: String? = null
+
+ @NotNull
+ @Column(name = "filesize")
+ open var filesize: Long? = null
+
+ @Column(name = "remarks", length = 500)
+ open var remarks: String? = null
+}
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/file/entity/FileBlob.kt b/src/main/java/com/ffii/tsms/modules/file/entity/FileBlob.kt
new file mode 100644
index 0000000..625c466
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/file/entity/FileBlob.kt
@@ -0,0 +1,24 @@
+package com.ffii.tsms.modules.file.entity
+
+import com.ffii.core.entity.BaseEntity
+import com.ffii.core.entity.IdEntity
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.JoinColumn
+import jakarta.persistence.OneToOne
+import jakarta.persistence.Table
+import jakarta.validation.constraints.NotNull
+
+@Entity
+@Table(name = "file_blob")
+open class FileBlob : BaseEntity() {
+
+ @NotNull
+ @OneToOne
+ @JoinColumn(name = "fileId")
+ open var file: File? = null
+
+ @NotNull
+ @Column(name = "bytes")
+ open var bytes: ByteArray? = null
+}
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/file/entity/FileBlobRepository.kt b/src/main/java/com/ffii/tsms/modules/file/entity/FileBlobRepository.kt
new file mode 100644
index 0000000..cc84def
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/file/entity/FileBlobRepository.kt
@@ -0,0 +1,9 @@
+package com.ffii.tsms.modules.file.entity
+
+import com.ffii.core.support.AbstractRepository
+import org.springframework.data.repository.query.Param
+import java.util.Optional
+
+interface FileBlobRepository : AbstractRepository {
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/file/entity/FileRef.kt b/src/main/java/com/ffii/tsms/modules/file/entity/FileRef.kt
new file mode 100644
index 0000000..e57a3f7
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/file/entity/FileRef.kt
@@ -0,0 +1,31 @@
+package com.ffii.tsms.modules.file.entity
+
+import com.ffii.core.entity.BaseEntity
+import com.ffii.core.entity.IdEntity
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.JoinColumn
+import jakarta.persistence.OneToOne
+import jakarta.persistence.Table
+import jakarta.validation.constraints.NotNull
+
+@Entity
+@Table(name = "file_ref")
+open class FileRef : BaseEntity() {
+
+ @NotNull
+ @Column(name = "refType", length = 20)
+ open var refType: String? = null
+
+ @NotNull
+ @Column(name = "refId")
+ open var refId: Long? = null
+
+ @Column(name = "refCode")
+ open var refCode: String? = null
+
+ @NotNull
+ @OneToOne
+ @JoinColumn(name = "fileId")
+ open var file: File? = null
+}
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/file/entity/FileRefRepository.kt b/src/main/java/com/ffii/tsms/modules/file/entity/FileRefRepository.kt
new file mode 100644
index 0000000..6753329
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/file/entity/FileRefRepository.kt
@@ -0,0 +1,6 @@
+package com.ffii.tsms.modules.file.entity
+
+import com.ffii.core.support.AbstractRepository
+
+interface FileRefRepository : AbstractRepository {
+}
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/file/entity/FileRepository.kt b/src/main/java/com/ffii/tsms/modules/file/entity/FileRepository.kt
new file mode 100644
index 0000000..88f8c7e
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/file/entity/FileRepository.kt
@@ -0,0 +1,6 @@
+package com.ffii.tsms.modules.file.entity
+
+import com.ffii.core.support.AbstractRepository
+
+interface FileRepository : AbstractRepository {
+}
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/file/service/FileService.kt b/src/main/java/com/ffii/tsms/modules/file/service/FileService.kt
new file mode 100644
index 0000000..a1150c8
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/file/service/FileService.kt
@@ -0,0 +1,326 @@
+package com.ffii.tsms.modules.file.service
+
+import com.ffii.core.support.AbstractBaseEntityService
+import com.ffii.core.support.JdbcDao
+import com.ffii.core.utils.FileUtils
+import com.ffii.core.utils.NumberUtils
+import com.ffii.core.utils.Params
+import com.ffii.tsms.modules.file.entity.*
+import org.apache.commons.lang3.RandomStringUtils
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Isolation
+import org.springframework.transaction.annotation.Transactional
+import org.springframework.web.multipart.MultipartFile
+import java.io.ByteArrayInputStream
+import java.io.IOException
+import java.util.*
+import java.util.Map
+import javax.imageio.ImageIO
+
+
+@Service
+open class FileService(
+ private val jdbcDao: JdbcDao,
+ private val fileRepository: FileRepository,
+ private val fileRefRepository: FileRefRepository,
+ private val fileBlobRepository: FileBlobRepository,
+) : AbstractBaseEntityService(jdbcDao, fileRepository) {
+
+ @Transactional(isolation = Isolation.SERIALIZABLE, rollbackFor = [Exception::class], readOnly = false)
+ open fun saveFile(instance: File): File {
+ return fileRepository.save(instance)
+ }
+
+ @Transactional(isolation = Isolation.SERIALIZABLE, rollbackFor = [Exception::class], readOnly = false)
+ open fun saveFileBlob(instance: FileBlob): FileBlob {
+ return fileBlobRepository.save(instance)
+ }
+
+ @Transactional(isolation = Isolation.SERIALIZABLE, rollbackFor = [Exception::class], readOnly = false)
+ open fun saveFileRef(instance: FileRef): FileRef {
+ return fileRefRepository.save(instance)
+ }
+
+ @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = [Exception::class], readOnly = true)
+ open fun findFileByIdAndKey(id: Long, skey: String): Optional {
+ return jdbcDao.queryForEntity(
+ "SELECT * from file f where f.id = :id and f.skey = :skey ",
+ Map.of("id", id, "skey", skey), File::class.java
+ )
+ }
+
+ @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = [Exception::class], readOnly = true)
+ open fun findFileRefByTypeAndId(refType: String, refId: Long): List {
+ return jdbcDao.queryForList(
+ "SELECT * from file_ref where refType = :refType and refId = :refId order by id ",
+ java.util.Map.of("refType", refType, "refId", refId), FileRef::class.java
+ )
+ }
+
+ @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = [Exception::class], readOnly = true)
+ open fun findFileRefByFileId(fileId: Long): Optional {
+ return jdbcDao.queryForEntity(
+ "SELECT * from file_ref where fileId = :fileId", java.util.Map.of("fileId", fileId),
+ FileRef::class.java
+ )
+ }
+
+ @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = [Exception::class], readOnly = true)
+ open fun findFileBlobByFileId(fileId: Long): Optional {
+ return jdbcDao.queryForEntity(
+ "SELECT * from file_blob fb where fb.fileId = :fileId ",
+ java.util.Map.of("fileId", fileId), FileBlob::class.java
+ )
+ }
+
+ @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = [Exception::class], readOnly = true)
+ open fun isFileExists(id: Long?, skey: String?): Boolean {
+ val sql = ("SELECT"
+ + " COUNT(1)"
+ + " FROM file f"
+ + " WHERE f.deleted = 0"
+ + " AND f.id = :id"
+ + " AND f.skey = :skey")
+
+ val count = jdbcDao.queryForInt(sql, Map.of("id", id, "skey", skey))
+
+ return (count > 0)
+ }
+
+ @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = [Exception::class], readOnly = true)
+ open fun searchFiles(args: MutableMap): MutableList>? {
+ val sql = StringBuilder(
+ "SELECT"
+ + " f.id,"
+ + " f.filename,"
+ + " f.filesize,"
+ + " f.skey,"
+ + " fr.refId,"
+ + " fr.refType,"
+ + " f.created,"
+ + " u.name AS createdByName,"
+ + " f.description"
+ + " FROM file f"
+ + " LEFT JOIN file_ref fr ON f.id = fr.fileId"
+ + " LEFT JOIN user u ON f.createdBy = u.id"
+ + " WHERE f.deleted = 0"
+ )
+
+ if (args.containsKey("filename")) sql.append(" AND f.filename = :filename")
+
+ if (args.containsKey("refType")) sql.append(" AND fr.refType = :refType")
+
+ if (args.containsKey("refId")) sql.append(" AND fr.refId = :refId")
+
+ if (args.containsKey("startDate")) sql.append(" AND f.created >= :startDate")
+
+ if (args.containsKey("endDate")) sql.append(" AND f.created < :endDate")
+
+ sql.append(" ORDER BY f.created DESC")
+
+ return jdbcDao.queryForList(sql.toString(), args)
+ }
+
+ /**
+ * Delete `FileRef` by `fileId`, `refId`,
+ * `refType`, and `skey`
+ */
+ @Transactional(isolation = Isolation.SERIALIZABLE, rollbackFor = [Exception::class], readOnly = false)
+ open fun deleteFile(fileId: Long, refId: Long, refType: String, skey: String) {
+ val args =
+ Map.of("fileId", fileId, "refId", refId, "refType", refType, "skey", skey)
+
+ jdbcDao.executeUpdate(
+ ("DELETE FROM file_ref"
+ + " WHERE fileId = :fileId"
+ + " AND refId = :refId"
+ + " AND refType = :refType"
+ + " AND EXISTS (SELECT 1 FROM file WHERE id = file_ref.fileId AND skey = :skey)"),
+ args
+ )
+ }
+
+ @Transactional(isolation = Isolation.SERIALIZABLE, rollbackFor = [Exception::class], readOnly = false)
+ open fun deleteFile(fileId: Long?, skey: String?, filename: String?) {
+ val args = Map.of("fileId", fileId, "skey", skey, "filename", filename)
+
+ jdbcDao.executeUpdate(
+ ("DELETE FROM file_ref "
+ + " WHERE fileId = :fileId "
+ + " AND EXISTS (SELECT 1 FROM file WHERE id = file_ref.fileId AND skey = :skey AND filename = :filename)"),
+ args
+ )
+ }
+
+ @Transactional(isolation = Isolation.SERIALIZABLE, rollbackFor = [Exception::class], readOnly = false)
+ open fun saveFile(
+ filename: String,
+ description: String,
+ refType: String,
+ refId: Long,
+ refCode: String,
+ bytes: ByteArray
+ ): Long {
+ val file: File = File()
+
+ file.apply {
+ this.filename = filename
+ this.description = description
+ mimetype = FileUtils.guessMimetype(filename)
+ filesize = NumberUtils.toLong(bytes.size, 0L)
+ skey = RandomStringUtils.randomAlphanumeric(32)
+ }
+
+ val fileBlob: FileBlob = FileBlob()
+
+ fileBlob.apply {
+ this.bytes = bytes
+ }
+
+ val fileRef: FileRef = FileRef()
+
+ fileRef.apply {
+ this.refId = refId
+ this.refType = refType
+ this.refCode = refCode
+ }
+
+ // save File
+ fileRepository.save(file)
+
+ // save FileBlob
+ fileBlob.apply {
+ this.file = file
+ }
+ fileBlobRepository.save(fileBlob)
+
+ // save FileRef
+ fileRef.apply {
+ this.file = file
+ }
+ fileRefRepository.save(fileRef)
+
+ return file.id!!
+ }
+
+ @Transactional(rollbackFor = [Exception::class])
+ @Throws(IOException::class)
+ open fun uploadFile(
+ multipartFile: MultipartFile, refId: Long?, refType: String?, refCode: String?,
+ maxSize: Int
+ ): MutableMap {
+ val result: MutableMap = HashMap()
+
+ // filesize < 50MB
+ if (multipartFile.size > 0 && multipartFile.size <= maxSize * 1024 * 1024) {
+ // DEBUG LOG
+
+ logger.info("multipartFile.getSize() = " + multipartFile.size)
+
+ val file: File = File()
+
+ file.apply {
+ filename = multipartFile.originalFilename
+ mimetype = FileUtils.guessMimetype(multipartFile.originalFilename)
+ filesize = multipartFile.size
+ skey = RandomStringUtils.randomAlphanumeric(32)
+ }
+
+ // get height and width if mimetype is png or jpeg
+// if (AbstractView.CONTENT_TYPE_PNG.equals(file.getMimetype())
+// || AbstractView.CONTENT_TYPE_JPEG.equals(file.getMimetype())
+// ) {
+// val image = ImageIO.read(ByteArrayInputStream(fileBlob.getBytes()))
+// if (image != null) {
+// file.setImageHeight(image.height)
+// file.setImageWidth(image.width)
+// }
+// }
+
+ // create UserFile
+ saveFile(file)
+
+ // create UserFileBlob
+ val fileBlob: FileBlob = FileBlob()
+ fileBlob.apply {
+ bytes = multipartFile.bytes
+ this.file = file
+ }
+ saveFileBlob(fileBlob)
+
+ // create UserFileRef
+ val fileRef: FileRef = FileRef()
+
+ fileRef.apply {
+ this.refId = refId
+ this.refType = refType
+ this.refCode = refCode
+ this.file = file
+ }
+ saveFileRef(fileRef)
+
+ result["fileId"] = file.id!!
+ result["skey"] = file.skey!!
+ result["filename"] = file.filename!!
+ } else {
+ result["success"] = java.lang.Boolean.FALSE
+ result[Params.MSG] = "Upload Failed"
+ }
+ return result
+ }
+
+ @Transactional(rollbackFor = [Exception::class])
+ @Throws(IOException::class)
+ open fun updateFile(
+ multipartFile: MultipartFile, refId: Long?, refType: String?, refCode: String?,
+ maxSize: Int, fileId: Long
+ ): MutableMap {
+ val result: MutableMap = HashMap()
+
+ // filesize < 50MB
+ if (multipartFile.size > 0 && multipartFile.size <= maxSize * 1024 * 1024) {
+ // DEBUG LOG
+
+ logger.info("multipartFile.getSize() = " + multipartFile.size)
+
+ val file: File = fileRepository.findById(fileId).orElse(File())
+
+ file.apply {
+ filename = multipartFile.originalFilename
+ mimetype = FileUtils.guessMimetype(multipartFile.originalFilename)
+ filesize = multipartFile.size
+ skey = RandomStringUtils.randomAlphanumeric(32)
+ }
+
+ // create UserFile
+ saveFile(file)
+
+ // create UserFileBlob
+ val fileBlob: FileBlob = findFileBlobByFileId(fileId).orElse(FileBlob())
+ fileBlob.apply {
+ bytes = multipartFile.bytes
+ this.file = file
+ }
+ saveFileBlob(fileBlob)
+
+ // create UserFileRef
+ val fileRef: FileRef = findFileRefByFileId(fileId).orElse(FileRef())
+
+ fileRef.apply {
+ this.refId = refId
+ this.refType = refType
+ this.refCode = refCode
+ this.file = file
+ }
+ saveFileRef(fileRef)
+
+ result["fileId"] = file.id!!
+ result["skey"] = file.skey!!
+ result["filename"] = file.filename!!
+ } else {
+ result["success"] = java.lang.Boolean.FALSE
+ result[Params.MSG] = "Update Failed"
+ }
+ return result
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/ffii/tsms/modules/file/web/DownloadFileContoller.kt b/src/main/java/com/ffii/tsms/modules/file/web/DownloadFileContoller.kt
new file mode 100644
index 0000000..64dcb51
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/file/web/DownloadFileContoller.kt
@@ -0,0 +1,133 @@
+package com.ffii.tsms.modules.file.web
+
+import com.ffii.core.utils.DateUtils
+import com.ffii.core.utils.NumberUtils
+import com.ffii.core.utils.StringUtils
+import com.ffii.tsms.modules.file.entity.File
+import com.ffii.tsms.modules.file.entity.FileBlob
+import com.ffii.tsms.modules.file.service.FileService
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import org.springframework.beans.factory.annotation.Autowired
+import java.text.DecimalFormat
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.bind.annotation.RestController
+import java.io.IOException
+import java.net.URLEncoder
+import java.util.Date
+
+@RestController
+@RequestMapping("/file")
+open class DownloadFileController (private val fileService: FileService) {
+ private val logger: Log = LogFactory.getLog(javaClass)
+
+ private val ZIP_NUMBERER : Boolean = false
+
+ @GetMapping("/dl/{id}/{skey}/{filename}")
+ @Throws(IOException::class)
+ open fun download(
+ request: HttpServletRequest, response: HttpServletResponse, @RequestParam(defaultValue = "false") dl: Boolean,
+ @PathVariable id: Long, @PathVariable skey: String, @PathVariable filename: String
+ ) {
+ val file: File = fileService.findFileByIdAndKey(id, skey).get()
+
+ if (file != null) {
+ val fileBlob: FileBlob = fileService.findFileBlobByFileId(id).get()
+
+ response.reset()
+ response.contentType = file.mimetype
+ response.setContentLength(NumberUtils.toInt(file.filesize, 0))
+ response.setHeader("Content-Transfer-Encoding", "binary")
+ response.setHeader("Access-Control-Expose-Headers", "Content-Disposition")
+ response.setHeader(
+ "Content-Disposition",
+ java.lang.String.format(
+ "%s; filename=\"%s\"",
+ if (dl) "attachment" else "inline",
+ URLEncoder.encode(file.filename, "UTF-8")
+ )
+ )
+
+
+ // String origin = request.getHeader("Origin");
+ //TODO: anna Access-Control-Allow-Origin
+ response.addHeader("Access-Control-Allow-Origin", "*")
+ response.setHeader("Access-Control-Allow-Methods", "OPTIONS, GET")
+
+ val out = response.outputStream
+
+ try {
+ out.write(fileBlob.bytes!!)
+ out.flush()
+ out.close()
+ } catch (e: IOException) {
+ logger.warn(e.message)
+ } finally {
+ out.close()
+ }
+ } else {
+ logger.info("*** 400 BAD REQUEST ***")
+ response.status = HttpServletResponse.SC_BAD_REQUEST
+ }
+ }
+
+ @GetMapping(value = ["/dlz"], produces = ["application/zip"])
+ @Throws(IOException::class)
+ open fun downloadAsZip(
+ response: HttpServletResponse,
+ @RequestParam filename: String, @RequestParam ids: String, @RequestParam skeys: String
+ ) {
+ var filename = filename
+ filename += DateUtils.formatDate(Date(), "_yyyy_MM_dd_HHmm", "") + ".zip"
+ response.setHeader("Access-Control-Expose-Headers", "Content-Disposition")
+ response.setHeader("Content-Transfer-Encoding", "binary")
+ response.setHeader(
+ "Content-Disposition",
+ String.format("%s; filename=\"%s\"", "attachment", response.encodeURL(filename))
+ )
+
+ val df00 = DecimalFormat("00")
+
+ val zipOutputStream = ZipOutputStream(response.outputStream)
+
+ val idsA: Array = StringUtils.split(ids, ',')
+ val skeysA: Array = StringUtils.split(skeys, ',')
+
+ var filePrefixIdx = 1
+
+ for (i in idsA.indices) {
+ val id: Long = NumberUtils.longValue(idsA[i])
+ val skey = skeysA[i]
+
+ val file: File = fileService.findFileByIdAndKey(id, skey).get()
+
+ if (file == null) {
+ logger.warn("*** file is null, id = $id, skey = $skey")
+ } else {
+ val fileBlob: FileBlob = fileService.findFileBlobByFileId(id).get()
+
+ if (fileBlob == null) {
+ logger.warn("*** fileBlob is null, id = $id, skey = $skey")
+ } else {
+ zipOutputStream.putNextEntry(
+ ZipEntry(
+ if (ZIP_NUMBERER) (df00.format(filePrefixIdx++.toLong()) + "_" + file.filename)
+ else file.filename
+ )
+ )
+ zipOutputStream.write(fileBlob.bytes)
+ zipOutputStream.closeEntry()
+ }
+ }
+ }
+ zipOutputStream.flush()
+ zipOutputStream.close()
+ }
+}
diff --git a/src/main/java/com/ffii/tsms/modules/file/web/FileController.kt b/src/main/java/com/ffii/tsms/modules/file/web/FileController.kt
new file mode 100644
index 0000000..12f4a19
--- /dev/null
+++ b/src/main/java/com/ffii/tsms/modules/file/web/FileController.kt
@@ -0,0 +1,67 @@
+package com.ffii.tsms.modules.file.web
+
+import com.ffii.core.response.RecordsRes
+import com.ffii.core.utils.Params
+import com.ffii.tsms.modules.file.entity.File
+import com.ffii.tsms.modules.file.service.FileService
+import com.ffii.tsms.modules.file.web.model.FileReq
+import com.ffii.tsms.modules.file.web.model.UpdateFileReq
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import jakarta.validation.Valid
+import org.springframework.ui.Model
+import org.springframework.web.bind.ServletRequestBindingException
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+@RequestMapping(value = ["/file"])
+open class FileController (private val fileService: FileService) {
+
+ @PostMapping("/list")
+ @Throws(ServletRequestBindingException::class)
+ fun listJson(
+ model: Model?,
+ request: HttpServletRequest?,
+ @RequestBody req: @Valid FileReq
+ ): RecordsRes