/* * Copyright 2011-2013 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * 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 kr.debop4j.timeperiod.calendars; import kr.debop4j.core.Pair; import kr.debop4j.core.ValueObjectBase; import kr.debop4j.core.tools.StringTool; import kr.debop4j.timeperiod.*; import kr.debop4j.timeperiod.timeline.TimeGapCalculator; import kr.debop4j.timeperiod.tools.Durations; import kr.debop4j.timeperiod.tools.TimeSpec; import lombok.Getter; import org.joda.time.DateTime; import org.joda.time.Duration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static kr.debop4j.core.Guard.shouldBe; /** * 특정 시각과 기간(Duration)을 이용하여 상대 시각을 구합니다. (중간에 제외할 요일이나 일자를 고려합니다) * * @author 배성혁 sunghyouk.bae@gmail.com * @since 13. 5. 19. 오후 11:39 */ public class DateAdd extends ValueObjectBase { private static final Logger log = LoggerFactory.getLogger(DateAdd.class); private static final boolean isTraceEnable = log.isTraceEnabled(); private static final boolean isDebugEnable = log.isDebugEnabled(); @Getter private final ITimePeriodCollection includePeriods = new TimePeriodCollection(); @Getter private final ITimePeriodCollection excludePeriods = new TimePeriodCollection(); /** 기본 생성자 */ public DateAdd() { } /** start 시각으로부터 offset 기간이 지난 시각을 계산합니다. */ public DateTime add(DateTime start, Duration offset) { return add(start, offset, SeekBoundaryMode.Next); } /** start 시각으로부터 offset 기간이 지난 시각을 계산합니다. */ public DateTime add(DateTime start, Duration offset, SeekBoundaryMode seekBoundary) { if (isTraceEnable) log.trace("Add. start=[{}] + offset=[{}]의 시각을 계산합니다. seekBoundaryMode=[{}]", start, offset, seekBoundary); if (getIncludePeriods().size() == 0 && getExcludePeriods().size() == 0) return start.plus(offset); Pair<DateTime, Duration> results = offset.compareTo(Duration.ZERO) < 0 ? calculateEnd(start, Durations.negate(offset), SeekDirection.Backward, seekBoundary) : calculateEnd(start, offset, SeekDirection.Forward, seekBoundary); DateTime end = (results != null) ? results.getV1() : null; Duration remaining = (results != null) ? results.getV2() : null; if (isDebugEnable) log.debug("Add. start=[{}] + offset=[{}] 의 결과 end=[{}], remaining=[{}]입니다. seekBoundaryMode=[{}]", start, offset, end, remaining, seekBoundary); return end; } /** start 시각으로부터 offset 기간을 뺀 (즉 이전의) 시각을 계산합니다. */ public DateTime subtract(DateTime start, Duration offset) { return subtract(start, offset, SeekBoundaryMode.Next); } /** start 시각으로부터 offset 기간을 뺀 (즉 이전의) 시각을 계산합니다. */ public DateTime subtract(DateTime start, Duration offset, SeekBoundaryMode seekBoundary) { if (isTraceEnable) log.trace("Subtract. start=[{}] - offset=[{}]의 시각을 계산합니다. seekBoundaryMode=[{}]", start, offset, seekBoundary); Pair<DateTime, Duration> results = offset.compareTo(Duration.ZERO) < 0 ? calculateEnd(start, Durations.negate(offset), SeekDirection.Forward, seekBoundary) : calculateEnd(start, offset, SeekDirection.Backward, seekBoundary); DateTime end = (results != null) ? results.getV1() : null; Duration remaining = (results != null) ? results.getV2() : null; if (isDebugEnable) log.debug("Subtract. start=[{}] - offset=[{}] 의 결과 end=[{}], remaining=[{}]입니다. seekBoundaryMode=[{}]", start, offset, end, remaining, seekBoundary); return end; } /** * 기준 시각으로부터 offset 만큼 떨어진 시각을 구합니다. * * @param start 기준 시각 * @param offset 기간 * @param seekDir 탐색 방향 * @param seekBoundary 경계 값 포함 여부 * @return 계산된 시각, 짜투리 시 */ protected Pair<DateTime, Duration> calculateEnd(DateTime start, Duration offset, SeekDirection seekDir, SeekBoundaryMode seekBoundary) { if (isTraceEnable) log.trace("기준시각으로부터 오프셋만큼 떨어진 시각을 구합니다... start=[{}], offset=[{}], seekDir=[{}], seekBoundary=[{}]", start, offset, seekDir, seekBoundary); shouldBe(offset.compareTo(Duration.ZERO) >= 0, "offset값은 0 이상이어야 합니다. offset=[%d]", offset.getMillis()); Duration remaining = offset; DateTime end; // search periods ITimePeriodCollection searchPeriods = new TimePeriodCollection(this.includePeriods); if (searchPeriods.size() == 0) searchPeriods.add(TimeRange.Anytime); // available periods ITimePeriodCollection availablePeriods = new TimePeriodCollection(); if (excludePeriods.size() == 0) { availablePeriods.addAll(searchPeriods); } else { if (isTraceEnable) log.trace("예외 기간을 제외합니다."); TimeGapCalculator<TimeRange> gapCalculator = new TimeGapCalculator<>(); for (ITimePeriod p : searchPeriods) { if (excludePeriods.hasOverlapPeriods(p)) { if (isTraceEnable) log.trace("예외 기간에 속하지 않는 부분만을 추려냅니다"); for (ITimePeriod gap : gapCalculator.getGaps(excludePeriods, p)) availablePeriods.add(gap); } else { availablePeriods.add(p); } } } if (availablePeriods.size() == 0) { if (isTraceEnable) log.trace("유효한 period 가 없어서 중단합니다."); return Pair.create(null, remaining); } if (isTraceEnable) log.trace("유효기간 중 중복된 부분을 제거하기 위해 기간들을 결합합니다..."); TimePeriodCombiner periodCombiner = new TimePeriodCombiner<TimeRange>(); availablePeriods = periodCombiner.combinePeriods(availablePeriods); if (isTraceEnable) log.trace("첫 시작을 찾습니다."); Pair<ITimePeriod, DateTime> result = (seekDir == SeekDirection.Forward) ? findNextPeriod(start, availablePeriods) : findPrevPeriod(start, availablePeriods); ITimePeriod startPeriod = result.getV1(); DateTime seekMoment = result.getV2(); // 첫 시작 기간이 없다면 중단합니다. if (startPeriod == null) { if (isTraceEnable) log.trace("첫 시작 기간이 없어서 중단합니다."); return Pair.create(null, remaining); } // offset 값이 0 이라면, 바로 다음 값이므로 seekMoment 를 반환합니다. if (offset.isEqual(Duration.ZERO)) { if (isTraceEnable) log.trace("offset 값이 0이므로, 바로 다음 값인 seekMoment를 반환합니다."); return Pair.create(seekMoment, remaining); } if (seekDir == SeekDirection.Forward) { for (int i = availablePeriods.indexOf(startPeriod); i < availablePeriods.size(); i++) { ITimePeriod gap = availablePeriods.get(i); Duration gapRemaining = new Duration(seekMoment, gap.getEnd()); if (isTraceEnable) log.trace("Seek forward. gap=[{}], gapRemaining=[{}], remaining=[{}], seekMoment=[{}]", gap, gapRemaining, remaining, seekMoment); boolean isTargetPeriod = (seekBoundary == SeekBoundaryMode.Fill) ? gapRemaining.compareTo(remaining) >= 0 : gapRemaining.compareTo(remaining) > 0; if (isTargetPeriod) { end = seekMoment.plus(remaining); remaining = null; return Pair.create(end, remaining); } remaining = remaining.minus(gapRemaining); if (i == availablePeriods.size() - 1) return Pair.create(null, remaining); seekMoment = availablePeriods.get(i + 1).getStart(); // next period } } else { for (int i = availablePeriods.indexOf(startPeriod); i >= 0; i--) { ITimePeriod gap = availablePeriods.get(i); Duration gapRemaining = new Duration(gap.getStart(), seekMoment); if (isTraceEnable) log.trace("Seek backward. gap=[{}], gapRemaining=[{}], remaining=[{}], seekMoment=[{}]", gap, gapRemaining, remaining, seekMoment); boolean isTargetPeriod = (seekBoundary == SeekBoundaryMode.Fill) ? gapRemaining.compareTo(remaining) >= 0 : gapRemaining.compareTo(remaining) > 0; if (isTargetPeriod) { end = seekMoment.minus(remaining); remaining = null; return Pair.create(end, remaining); } remaining = remaining.minus(gapRemaining); if (i == 0) return Pair.create(null, remaining); seekMoment = availablePeriods.get(i - 1).getEnd(); } } if (isTraceEnable) log.trace("해당하는 일자를 찾지 못했습니다."); return Pair.create(null, remaining); } /** * start가 periods의 기간 중에 가장 가까운 기간에 속해 앴으면 그 값을 반환하고, 아니면 start와 가장 근접한 후행 period를 찾는다. * * @param start 검색 일자 * @param periods 대상 기간들 * @return period와 후행 기간의 시작 일자 */ private static Pair<ITimePeriod, DateTime> findNextPeriod(DateTime start, Iterable<? extends ITimePeriod> periods) { if (isTraceEnable) log.trace("시작시각의 이후 기간을 찾습니다... start=[{}], periods=[{}]", start, StringTool.listToString(periods)); ITimePeriod nearest = null; DateTime moment = start; Duration difference = TimeSpec.MaxDuration; for (ITimePeriod period : periods) { // 기간이 start 이전이라면 (before) if (period.getEnd().compareTo(start) < 0) continue; // start가 기간에 속한다면... if (period.hasInside(start)) { nearest = period; moment = start; break; } // 근처 값이 아니라면 포기 Duration periodToMoment = new Duration(start, period.getStart()); if (periodToMoment.compareTo(difference) >= 0) continue; difference = periodToMoment; nearest = period; moment = period.getStart(); } if (isTraceEnable) log.trace("시작시각의 이후 기간을 찾았습니다. start=[{}], moment=[{}], neearest=[{}]", start, moment, nearest); return Pair.create(nearest, moment); } /** * start가 periods 기간 중에 가장 가까운 기간에 속해 있으면 그 값을 반환하고, 아니면 start와 가장 근접한 선행 period를 찾는다. * * @param start 검색 일자 * @param periods 대상 기간들 * @return period와 선행 기간의 완료 일자 */ private static Pair<ITimePeriod, DateTime> findPrevPeriod(DateTime start, Iterable<? extends ITimePeriod> periods) { if (isTraceEnable) log.trace("시작시각의 이전 기간을 찾습니다... start=[{}], periods=[{}]", start, StringTool.listToString(periods)); ITimePeriod nearest = null; DateTime moment = start; Duration difference = TimeSpec.MaxDuration; for (ITimePeriod period : periods) { // 기간이 start 이후이라면 (after) if (period.getStart().compareTo(start) > 0) continue; // start가 기간에 속한다면... if (period.hasInside(start)) { nearest = period; moment = start; break; } // 근처 값이 아니라면 포기 Duration periodToMoment = new Duration(start, period.getEnd()); if (periodToMoment.compareTo(difference) >= 0) continue; difference = periodToMoment; nearest = period; moment = period.getEnd(); } if (isTraceEnable) log.trace("시작시각의 이전 기간을 찾았습니다. start=[{}], moment=[{}], neearest=[{}]", start, moment, nearest); return Pair.create(nearest, moment); } private static final long serialVersionUID = 2352433294158169198L; }