diff --git a/app-monolith/src/main/java/pl/com/bottega/factory/delivery/planning/projection/DeliveryForecastProjection.java b/app-monolith/src/main/java/pl/com/bottega/factory/delivery/planning/projection/DeliveryForecastProjection.java index 24a8c49..4a9eab0 100644 --- a/app-monolith/src/main/java/pl/com/bottega/factory/delivery/planning/projection/DeliveryForecastProjection.java +++ b/app-monolith/src/main/java/pl/com/bottega/factory/delivery/planning/projection/DeliveryForecastProjection.java @@ -5,7 +5,7 @@ import org.springframework.stereotype.Component; import pl.com.bottega.factory.delivery.planning.DeliveryAutoPlanner; import pl.com.bottega.factory.delivery.planning.DeliveryAutoPlannerORMRepository; import pl.com.bottega.factory.demand.forecasting.Demand; -import pl.com.bottega.factory.demand.forecasting.DemandEvents; +import pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged; import pl.com.bottega.factory.demand.forecasting.projection.CurrentDemandDao; import pl.com.bottega.factory.demand.forecasting.projection.CurrentDemandEntity; @@ -15,15 +15,14 @@ import java.util.List; @Component @AllArgsConstructor -public class DeliveryForecastProjection implements DemandEvents { +public class DeliveryForecastProjection { private final Clock clock; private final DeliveryForecastDao forecastDao; private final CurrentDemandDao demandDao; private final DeliveryAutoPlannerORMRepository planners; - @Override - public void emit(DemandedLevelsChanged event) { + public void persistDeliveryForecasts(DemandedLevelsChanged event) { DeliveryAutoPlanner planner = planners.get(event.getRefNo().getRefNo()); event.getResults().keySet() .forEach(daily -> forecastDao.deleteByRefNoAndDate( diff --git a/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandEventsMapping.java b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandEventsMapping.java index 6523da7..09ce956 100644 --- a/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandEventsMapping.java +++ b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandEventsMapping.java @@ -4,9 +4,15 @@ import lombok.AllArgsConstructor; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; import pl.com.bottega.factory.delivery.planning.projection.DeliveryForecastProjection; +import pl.com.bottega.factory.demand.forecasting.command.DemandReviewDao; +import pl.com.bottega.factory.demand.forecasting.command.DemandReviewEntity; import pl.com.bottega.factory.demand.forecasting.projection.CurrentDemandProjection; import pl.com.bottega.factory.shortages.prediction.ShortagePredictionEventsMapping; +import java.time.Clock; +import java.time.Instant; +import java.util.stream.Collectors; + @Lazy @Component @AllArgsConstructor @@ -15,11 +21,21 @@ class DemandEventsMapping implements DemandEvents { private final CurrentDemandProjection demands; private final DeliveryForecastProjection deliveries; private final ShortagePredictionEventsMapping predictions; + private final DemandReviewDao reviews; + private final Clock clock; @Override public void emit(DemandedLevelsChanged event) { - demands.emit(event); - deliveries.emit(event); - predictions.emit(event); + demands.persistCurrentDemands(event); + deliveries.persistDeliveryForecasts(event); + predictions.predictShortages(event); + } + + @Override + public void emit(ReviewRequested event) { + reviews.save(event.getReviews().stream() + .map(review -> new DemandReviewEntity(Instant.now(clock), review)) + .collect(Collectors.toList()) + ); } } diff --git a/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandORMRepository.java b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandORMRepository.java index a4ddd78..1537260 100644 --- a/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandORMRepository.java +++ b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandORMRepository.java @@ -23,11 +23,12 @@ import static java.util.stream.Collectors.toMap; @AllArgsConstructor class DemandORMRepository { - private Clock clock; - private DemandEventsMapping events; - private EntityManager em; - private ProductDemandDao rootDao; - private DemandDao demandDao; + private final Clock clock; + private final DemandEventsMapping events; + private final ReviewPolicy reviewPolicy = ReviewPolicy.BASIC; + private final EntityManager em; + private final ProductDemandDao rootDao; + private final DemandDao demandDao; ProductDemand get(String refNo) { ProductDemandEntity root = rootDao.findByRefNo(refNo); @@ -53,11 +54,13 @@ class DemandORMRepository { .map(entity -> new DailyDemand( entity.createId(), unitOfWork, + reviewPolicy, entity.get().getDocumented(), entity.get().getAdjustment())) .orElseGet(() -> new DailyDemand( new DemandEntityId(refNo, date), unitOfWork, + reviewPolicy, null, null )); diff --git a/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandService.java b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandService.java index 039bd24..547f78b 100644 --- a/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandService.java +++ b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandService.java @@ -2,6 +2,7 @@ package pl.com.bottega.factory.demand.forecasting; import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; +import pl.com.bottega.factory.demand.forecasting.DemandEvents.ReviewRequested.ReviewNeeded; import javax.transaction.Transactional; @@ -23,4 +24,10 @@ public class DemandService { model.adjust(adjustDemand); repository.save(model); } + + public void review(ReviewNeeded review, ReviewDecision decision) { + ProductDemand model = repository.get(review.getRefNo()); + model.review(review, decision); + repository.save(model); + } } diff --git a/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/command/DemandReviewDao.java b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/command/DemandReviewDao.java new file mode 100644 index 0000000..17bf21c --- /dev/null +++ b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/command/DemandReviewDao.java @@ -0,0 +1,22 @@ +package pl.com.bottega.factory.demand.forecasting.command; + +import org.springframework.data.rest.core.annotation.RepositoryRestResource; +import org.springframework.data.rest.core.annotation.RestResource; +import org.springframework.stereotype.Repository; +import pl.com.bottega.tools.CommandRepository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +@RepositoryRestResource( + path = "demand-reviews", + collectionResourceRel = "demand-reviews", + itemResourceRel = "demand-review") +public interface DemandReviewDao extends CommandRepository { + @RestResource(path = "refNos", rel = "refNos") + List findByRefNoAndDecisionIsNull(String refNo); + + @RestResource(exported = false) + void deleteByCleanAfterGreaterThanEqual(LocalDate date); +} diff --git a/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/command/DemandReviewEntity.java b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/command/DemandReviewEntity.java new file mode 100644 index 0000000..adffe05 --- /dev/null +++ b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/command/DemandReviewEntity.java @@ -0,0 +1,59 @@ +package pl.com.bottega.factory.demand.forecasting.command; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import pl.com.bottega.factory.demand.forecasting.DemandEvents.ReviewRequested.ReviewNeeded; +import pl.com.bottega.factory.demand.forecasting.ReviewDecision; +import pl.com.bottega.tools.JsonConverter; + +import javax.persistence.*; +import java.io.Serializable; +import java.time.Instant; +import java.time.LocalDate; + +@Entity(name = "DemandReview") +@Getter +@NoArgsConstructor +@EqualsAndHashCode(of = "id") +public class DemandReviewEntity implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + @Column + private String refNo; + @Column + private LocalDate date; + @Column + private Instant timestamp; + @Column + @Convert(converter = ReviewAsJson.class) + private ReviewNeeded review; + + @Column + @Enumerated(EnumType.STRING) + private ReviewDecision decision; + @Column + @Setter + private LocalDate cleanAfter; + + public DemandReviewEntity(Instant timestamp, ReviewNeeded review) { + this.timestamp = timestamp; + this.refNo = review.getId().getRefNo(); + this.date = review.getId().getDate(); + this.review = review; + } + + public boolean decisionTaken() { + return decision != null; + } + + public static class ReviewAsJson extends JsonConverter { + public ReviewAsJson() { + super(ReviewNeeded.class); + } + } +} diff --git a/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/command/Handler.java b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/command/Handler.java index 14d4ae1..197ad08 100644 --- a/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/command/Handler.java +++ b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/command/Handler.java @@ -21,16 +21,25 @@ public class Handler { private final DemandService service; private final DemandAdjustmentDao adjustments; + private final DemandReviewDao reviews; private final Clock clock; @HandleBeforeCreate @HandleBeforeSave - public void adjustDemand(DemandAdjustmentEntity resource) { - LocalDate latest = resource.getAdjustment() + public void adjust(DemandAdjustmentEntity adjustment) { + LocalDate latest = adjustment.getAdjustment() .latestAdjustment() .orElse(LocalDate.now(clock)); - resource.setCleanAfter(latest.plusDays(7)); - service.adjust(resource.getAdjustment()); + adjustment.setCleanAfter(latest.plusDays(7)); + service.adjust(adjustment.getAdjustment()); + } + + @HandleBeforeSave + public void review(DemandReviewEntity review) { + if (review.decisionTaken()) { + review.setCleanAfter(LocalDate.now(clock).plusDays(7)); + service.review(review.getReview(), review.getDecision()); + } } @Scheduled(cron = "0 0 12 * * ?") @@ -38,5 +47,6 @@ public class Handler { @Transactional public void clean() { adjustments.deleteByCleanAfterGreaterThanEqual(LocalDate.now(clock)); + reviews.deleteByCleanAfterGreaterThanEqual(LocalDate.now(clock)); } } diff --git a/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/projection/CurrentDemandProjection.java b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/projection/CurrentDemandProjection.java index 85af1cf..75a5f6f 100644 --- a/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/projection/CurrentDemandProjection.java +++ b/app-monolith/src/main/java/pl/com/bottega/factory/demand/forecasting/projection/CurrentDemandProjection.java @@ -2,16 +2,15 @@ package pl.com.bottega.factory.demand.forecasting.projection; import lombok.AllArgsConstructor; import org.springframework.stereotype.Component; -import pl.com.bottega.factory.demand.forecasting.DemandEvents; +import pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged; @Component @AllArgsConstructor -public class CurrentDemandProjection implements DemandEvents { +public class CurrentDemandProjection { private final CurrentDemandDao demandDao; - @Override - public void emit(DemandedLevelsChanged event) { + public void persistCurrentDemands(DemandedLevelsChanged event) { event.getResults().forEach((daily, change) -> { demandDao.deleteByRefNoAndDate( daily.getRefNo(), diff --git a/app-monolith/src/main/java/pl/com/bottega/factory/shortages/prediction/ShortagePredictionEventsMapping.java b/app-monolith/src/main/java/pl/com/bottega/factory/shortages/prediction/ShortagePredictionEventsMapping.java index a682098..5134d2e 100644 --- a/app-monolith/src/main/java/pl/com/bottega/factory/shortages/prediction/ShortagePredictionEventsMapping.java +++ b/app-monolith/src/main/java/pl/com/bottega/factory/shortages/prediction/ShortagePredictionEventsMapping.java @@ -3,25 +3,24 @@ package pl.com.bottega.factory.shortages.prediction; import lombok.AllArgsConstructor; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; -import pl.com.bottega.factory.demand.forecasting.DemandEvents; +import pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged; import pl.com.bottega.factory.shortages.prediction.monitoring.ShortagePredictionProcess; import pl.com.bottega.factory.shortages.prediction.monitoring.ShortagePredictionProcessRepository; @Lazy @Component @AllArgsConstructor -public class ShortagePredictionEventsMapping implements DemandEvents { +public class ShortagePredictionEventsMapping { private final ShortagePredictionProcessRepository repository; - @Override - public void emit(DemandedLevelsChanged event) { + public void predictShortages(DemandedLevelsChanged event) { ShortagePredictionProcess model = repository.get(event.getRefNo()); model.onDemandChanged(); repository.save(model); } - //public void emit(ProductionChanged event) { service.onPlanChanged(event.getId().getRefNo()); } + //public void predictShortages(ProductionChanged event) { service.onPlanChanged(event.getId().getRefNo()); } - //public void emit(StockChanged event) { service.onStockChanged(event.getId().getRefNo()); } + //public void predictShortages(StockChanged event) { service.onStockChanged(event.getId().getRefNo()); } } diff --git a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Adjustment.java b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Adjustment.java index 275a691..82952ed 100644 --- a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Adjustment.java +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Adjustment.java @@ -12,7 +12,7 @@ class Adjustment { return new Adjustment(demand, true); } - static Adjustment week(Demand demand) { + static Adjustment weak(Demand demand) { return new Adjustment(demand, false); } diff --git a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DailyDemand.java b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DailyDemand.java index 20b0311..ae36940 100644 --- a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DailyDemand.java +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DailyDemand.java @@ -2,6 +2,7 @@ package pl.com.bottega.factory.demand.forecasting; import lombok.Value; import pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged.Change; +import pl.com.bottega.factory.demand.forecasting.DemandEvents.ReviewRequested.ReviewNeeded; import java.util.Objects; import java.util.Optional; @@ -10,6 +11,7 @@ class DailyDemand { private final DailyId id; private final Events events; + private final ReviewPolicy policy; private Demand documented; private Adjustment adjustment; @@ -17,15 +19,16 @@ class DailyDemand { interface Events { void emit(LevelChanged event); - void emit(ReviewRequest event); + void emit(ReviewNeeded event); void emit(DemandUpdated event); } - DailyDemand(DailyId id, Events events, + DailyDemand(DailyId id, Events events, ReviewPolicy policy, Demand documented, Adjustment adjustment) { this.id = id; this.events = events; + this.policy = policy; this.documented = Optional.ofNullable(documented) .orElse(Demand.nothingDemanded()); this.adjustment = adjustment; @@ -45,6 +48,13 @@ class DailyDemand { void update(Demand documented) { State state = state(); + if (policy.reviewNeeded(this.documented, this.adjustment, documented)) { + events.emit(new ReviewNeeded(id, + this.documented, + this.adjustment.getDemand(), + documented) + ); + } if (!Adjustment.isStrong(this.adjustment)) { this.adjustment = null; } @@ -70,14 +80,6 @@ class DailyDemand { Change change; } - @Value - static class ReviewRequest { - DailyId id; - Demand previousDocumented; - Demand strongAdjustment; - Demand newDocumented; - } - @Value static class DemandUpdated { DailyId id; diff --git a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/ProductDemand.java b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/ProductDemand.java index fe3f9ea..aff193c 100644 --- a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/ProductDemand.java +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/ProductDemand.java @@ -2,6 +2,8 @@ package pl.com.bottega.factory.demand.forecasting; import lombok.AllArgsConstructor; import pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged; +import pl.com.bottega.factory.demand.forecasting.DemandEvents.ReviewRequested; +import pl.com.bottega.factory.demand.forecasting.DemandEvents.ReviewRequested.ReviewNeeded; import pl.com.bottega.factory.product.management.RefNoId; import java.time.Clock; @@ -39,6 +41,15 @@ class ProductDemand { if (unit.anyChanges()) { events.emit(new DemandedLevelsChanged(id, unit.changes())); } + if (unit.anyReviews()) { + events.emit(new ReviewRequested(id, unit.reviews())); + } + } + + void review(ReviewNeeded review, ReviewDecision decision) { + if (decision.requireAdjustment()) { + adjust(decision.toAdjustment(review)); + } } private void adjustDaily(LocalDate date, Adjustment adjustment) { diff --git a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/ReviewDecision.java b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/ReviewDecision.java new file mode 100644 index 0000000..ca46374 --- /dev/null +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/ReviewDecision.java @@ -0,0 +1,33 @@ +package pl.com.bottega.factory.demand.forecasting; + +import lombok.AllArgsConstructor; +import pl.com.bottega.factory.demand.forecasting.DemandEvents.ReviewRequested.ReviewNeeded; + +import java.util.Collections; +import java.util.function.Function; + +@AllArgsConstructor +public enum ReviewDecision { + IGNORE(r -> null), + PICK_PREVIOUS(ReviewNeeded::getPreviousDocumented), + MAKE_ADJUSTMENT_WEAK(ReviewNeeded::getAdjustment), + PICK_NEW(ReviewNeeded::getNewDocumented); + + private final Function pick; + + public AdjustDemand toAdjustment(ReviewNeeded review) { + if (this == IGNORE) { + throw new IllegalStateException("can't convert " + this + " to adjustment"); + } + return new AdjustDemand(review.getRefNo(), + Collections.singletonMap( + review.getDate(), + Adjustment.weak(pick.apply(review)) + ) + ); + } + + public boolean requireAdjustment() { + return this != IGNORE; + } +} diff --git a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/ReviewPolicy.java b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/ReviewPolicy.java new file mode 100644 index 0000000..608374b --- /dev/null +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/ReviewPolicy.java @@ -0,0 +1,15 @@ +package pl.com.bottega.factory.demand.forecasting; + +public interface ReviewPolicy { + + ReviewPolicy BASIC = (previousDocumented, adjustment, newDocumented) -> + Adjustment.isStrong(adjustment) + && !newDocumented.equals(previousDocumented) + && !newDocumented.equals(adjustment.getDemand()); + + boolean reviewNeeded( + Demand previousDocumented, + Adjustment adjustment, + Demand newDocumented + ); +} diff --git a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/UnitOfWork.java b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/UnitOfWork.java index 1c33196..a008807 100644 --- a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/UnitOfWork.java +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/UnitOfWork.java @@ -1,20 +1,23 @@ package pl.com.bottega.factory.demand.forecasting; +import pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged; +import pl.com.bottega.factory.demand.forecasting.DemandEvents.ReviewRequested.ReviewNeeded; + import java.util.*; import static java.util.Collections.unmodifiableList; class UnitOfWork implements DailyDemand.Events { - Map changes = new HashMap<>(); - List reviews = new LinkedList<>(); + Map changes = new HashMap<>(); + List reviews = new LinkedList<>(); List updates = new LinkedList<>(); boolean anyChanges() { return !changes.isEmpty(); } - Map changes() { + Map changes() { return Collections.unmodifiableMap(changes); } @@ -22,7 +25,7 @@ class UnitOfWork implements DailyDemand.Events { return !reviews.isEmpty(); } - List reviews() { + List reviews() { return Collections.unmodifiableList(reviews); } @@ -36,7 +39,7 @@ class UnitOfWork implements DailyDemand.Events { } @Override - public void emit(DailyDemand.ReviewRequest event) { + public void emit(ReviewNeeded event) { reviews.add(event); } diff --git a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DailyDemandBuilder.groovy b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DailyDemandBuilder.groovy index a7381dc..f6ea9d0 100644 --- a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DailyDemandBuilder.groovy +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DailyDemandBuilder.groovy @@ -5,12 +5,14 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import static pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged.Change; +import static pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged.Change +import static pl.com.bottega.factory.demand.forecasting.DemandEvents.ReviewRequested.ReviewNeeded class DailyDemandBuilder { Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()) DailyDemand.Events events + ReviewPolicy policy = ReviewPolicy.BASIC String refNo = "3009000" private LocalDate date = LocalDate.now(clock) @@ -18,7 +20,7 @@ class DailyDemandBuilder { private Adjustment adjustment DailyDemand build() { - new DailyDemand(new DailyId(refNo, date), events, base, adjustment) + new DailyDemand(new DailyId(refNo, date), events, policy, base, adjustment) } DailyDemandBuilder reset() { @@ -60,6 +62,11 @@ class DailyDemandBuilder { this } + DailyDemandBuilder demandedLevels(Demand level) { + base = level + this + } + DailyDemandBuilder adjustedTo(long level) { adjustment = new Adjustment(Demand.of(level), false) this @@ -84,4 +91,13 @@ class DailyDemandBuilder { new Change(Demand.of(previous), Demand.of(current)) ) } + + ReviewNeeded reviewRequest(long previousDocumented, long adjustment, long newDocumented) { + new ReviewNeeded( + new DailyId(refNo, date), + Demand.of(previousDocumented), + Demand.of(adjustment), + Demand.of(newDocumented) + ) + } } diff --git a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandAdjustmentSpec.groovy b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandAdjustmentSpec.groovy index 926c7c1..319a7d5 100644 --- a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandAdjustmentSpec.groovy +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandAdjustmentSpec.groovy @@ -6,15 +6,18 @@ import java.time.LocalDate import static pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged -class DemandAdjustmentSpec extends Specification { +class DemandAdjustmentSpec extends Specification implements ProductDemandTrait { def events = Mock(DemandEvents) - def builder = new ProductDemandBuilder(events: events) + + void setup() { + builder = new ProductDemandBuilder(events: events) + } def "Adjusted demands should be stored"() { given: def today = LocalDate.now(builder.clock) - def demand = demand(2800, 0) + def demand = demanded(2800, 0) def adjustments = adjustments([(today): 1000]) when: @@ -27,7 +30,7 @@ class DemandAdjustmentSpec extends Specification { def "Adjustment of future demands is possible"() { given: def today = LocalDate.now(builder.clock) - def demand = demand(2800) + def demand = demanded(2800) def adjustments = adjustments([(today.plusDays(1)): 1000]) when: @@ -40,7 +43,7 @@ class DemandAdjustmentSpec extends Specification { def "Adjustment without changes should not generate event"() { given: def today = LocalDate.now(builder.clock) - def demand = demand(2800, 1000) + def demand = demanded(2800, 1000) def adjustments = adjustments([(today): 2800, (today.plusDays(1)): 1000]) when: @@ -53,7 +56,7 @@ class DemandAdjustmentSpec extends Specification { def "Should skip past demands adjustments"() { given: def pastDate = LocalDate.now(builder.clock).minusDays(2) - def demand = demand(2800, 0) + def demand = demanded(2800, 0) def adjustments = adjustments([(pastDate): 1000]) when: @@ -66,7 +69,7 @@ class DemandAdjustmentSpec extends Specification { def "Adjustment should be idempotent"() { given: def today = LocalDate.now(builder.clock) - def demand = demand(2800, 0) + def demand = demanded(2800, 0) def adjustments = adjustments((today): 2000, (today.plusDays(1)): 3500) when: @@ -82,20 +85,4 @@ class DemandAdjustmentSpec extends Specification { then: 0 * events.emit(_ as DemandedLevelsChanged) } - - ProductDemand demand(long ... levels) { - builder.demand(levels) - } - - AdjustDemand adjustments(Map map) { - builder.adjustDemand(map) - } - - DemandedLevelsChanged levelChanged(List... changes) { - builder.levelChanged(changes) - } - - List notChanged() { - [] - } } \ No newline at end of file diff --git a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandsRepositoryFake.groovy b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandsFake.groovy similarity index 52% rename from demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandsRepositoryFake.groovy rename to demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandsFake.groovy index 365b412..5647278 100644 --- a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandsRepositoryFake.groovy +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandsFake.groovy @@ -3,11 +3,11 @@ package pl.com.bottega.factory.demand.forecasting import java.time.Clock import java.time.LocalDate -class DemandsRepositoryFake extends Demands { +class DemandsFake extends Demands { DailyDemandBuilder builder - DemandsRepositoryFake(String refNo, UnitOfWork unitOfWork, Clock clock) { + DemandsFake(String refNo, UnitOfWork unitOfWork, Clock clock) { this.builder = new DailyDemandBuilder(refNo: refNo, events: unitOfWork, clock: clock) fetch = { date -> nothingDemanded(date) } } @@ -28,4 +28,24 @@ class DemandsRepositoryFake extends Demands { fetched.put(date, demand) demand } + + DailyDemand adjusted(LocalDate date, long level) { + def demand = builder.date(date) + .demandedLevels(fetched.get(date)?.level) + .adjustedTo(level) + .build() + + fetched.put(date, demand) + demand + } + + DailyDemand stronglyAdjusted(LocalDate date, long level) { + def demand = builder.date(date) + .demandedLevels(fetched.get(date)?.level) + .stronglyAdjustedTo(level) + .build() + + fetched.put(date, demand) + demand + } } diff --git a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DocumentProcessingSpec.groovy b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DocumentProcessingSpec.groovy index d34455b..5ce5b04 100644 --- a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DocumentProcessingSpec.groovy +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DocumentProcessingSpec.groovy @@ -6,15 +6,18 @@ import java.time.LocalDate import static pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged -class DocumentProcessingSpec extends Specification { +class DocumentProcessingSpec extends Specification implements ProductDemandTrait { def events = Mock(DemandEvents) - def builder = new ProductDemandBuilder(events: events) + + void setup() { + builder = new ProductDemandBuilder(events: events) + } def "Updated demands should be stored"() { given: def today = LocalDate.now(builder.clock) - def demand = demand(2800, 0) + def demand = demanded(2800, 0) def document = document(today, 2000, 3500) when: @@ -27,7 +30,7 @@ class DocumentProcessingSpec extends Specification { def "Demands for dates not present in system should be stored "() { given: def today = LocalDate.now(builder.clock) - def demand = demand(1000) + def demand = demanded(1000) def document = document(today, 1000, 3500, 1000) when: @@ -40,7 +43,7 @@ class DocumentProcessingSpec extends Specification { def "Document without changes should not generate event"() { given: def today = LocalDate.now(builder.clock) - def demand = demand(2800, 0) + def demand = demanded(2800, 0) def document = document(today, 2800, 0) when: @@ -53,7 +56,7 @@ class DocumentProcessingSpec extends Specification { def "Should skip past demands from document"() { given: def pastDate = LocalDate.now(builder.clock).minusDays(2) - def demand = demand(0, 0) + def demand = demanded(0, 0) def document = document(pastDate, 2800, 2800, 3500, 1000) when: @@ -66,7 +69,7 @@ class DocumentProcessingSpec extends Specification { def "Document processing should be idempotent"() { given: def today = LocalDate.now(builder.clock) - def demand = demand(2800, 0) + def demand = demanded(2800, 0) def document = document(today, 2000, 3500) when: @@ -82,20 +85,4 @@ class DocumentProcessingSpec extends Specification { then: 0 * events.emit(_ as DemandedLevelsChanged) } - - ProductDemand demand(long ... levels) { - builder.demand(levels) - } - - Document document(LocalDate date, long ... levels) { - builder.document(date, levels) - } - - DemandedLevelsChanged levelChanged(List... changes) { - builder.levelChanged(changes) - } - - List notChanged() { - [] - } } \ No newline at end of file diff --git a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/KeepingDailyDemandsSpec.groovy b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/KeepingDailyDemandsSpec.groovy index 7d52085..0dccb18 100644 --- a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/KeepingDailyDemandsSpec.groovy +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/KeepingDailyDemandsSpec.groovy @@ -1,6 +1,5 @@ package pl.com.bottega.factory.demand.forecasting -import spock.lang.PendingFeature import spock.lang.Specification class KeepingDailyDemandsSpec extends Specification { @@ -64,8 +63,7 @@ class KeepingDailyDemandsSpec extends Specification { 0 * events.emit(_ as DailyDemand.LevelChanged) } - @PendingFeature - def "Document update hidden by strong adjustment should rise warning"() { + def "Document update ignored by strong adjustment should rise warning"() { given: def demand = demand() .demandedLevels(2800) @@ -76,7 +74,7 @@ class KeepingDailyDemandsSpec extends Specification { then: demand.getLevel() == Demand.of(3500) - 1 * events.emit(_ as DailyDemand.ReviewRequest) + 1 * events.emit(reviewRequest(2800, 3500, 5000)) } DailyDemandBuilder demand() { @@ -95,4 +93,8 @@ class KeepingDailyDemandsSpec extends Specification { def levelChanged(long previous, long current) { builder.levelChanged(previous, current) } + + def reviewRequest(long previousDocumented, long adjustment, long newDocumented) { + builder.reviewRequest(previousDocumented, adjustment, newDocumented) + } } diff --git a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ProductDemandBuilder.groovy b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ProductDemandBuilder.groovy index 6d8e573..8c992b9 100644 --- a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ProductDemandBuilder.groovy +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ProductDemandBuilder.groovy @@ -5,26 +5,46 @@ import pl.com.bottega.factory.product.management.RefNoId import java.time.* import static DemandedLevelsChanged.Change +import static ReviewRequested.ReviewNeeded import static pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged +import static pl.com.bottega.factory.demand.forecasting.DemandEvents.ReviewRequested class ProductDemandBuilder { def refNo = "3009000" def unitOfWork = new UnitOfWork() - def demands = new DemandsRepositoryFake(refNo, unitOfWork, clock) + def demands = new DemandsFake(refNo, unitOfWork, clock) def clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()) DemandEvents events - ProductDemand demand(long ... levels) { + def demand(long ... levels) { def date = LocalDate.now(clock) for (long level : levels) { demands.demanded(date, level) date = date.plusDays(1) } + this + } + + def adjusted(Map adjustments) { + adjustments.each { date, level -> + demands.adjusted(date, level) + } + this + } + + def stronglyAdjusted(Map adjustments) { + adjustments.each { date, level -> + demands.stronglyAdjusted(date, level) + } + this + } + + def build() { new ProductDemand(new RefNoId(refNo), demands, unitOfWork, clock, events) } - Document document(LocalDate date, long ... levels) { + def document(LocalDate date, long ... levels) { def created = date.atTime(OffsetTime.of(8, 0, 0, 0, ZoneOffset.UTC)).toInstant() SortedMap results = new TreeMap<>() for (def level : levels) { @@ -34,15 +54,15 @@ class ProductDemandBuilder { new Document(created, refNo, results) } - AdjustDemand adjustDemand(Map adjustments) { + def adjustDemand(Map adjustments) { Map results = new HashMap<>() adjustments.forEach { date, level -> - results.put(date, Adjustment.week(Demand.of(level))) + results.put(date, Adjustment.weak(Demand.of(level))) } new AdjustDemand(refNo, results) } - DemandedLevelsChanged levelChanged(List... changes) { + def levelChanged(List... changes) { def date = LocalDate.now(clock) Map results = new HashMap<>() for (def change : changes) { @@ -50,12 +70,27 @@ class ProductDemandBuilder { results.put(new DailyId(refNo, date), new Change( Demand.of(change[0]), Demand.of(change[1]))) - } + } else if (!change.empty) throw new IllegalAccessException() date = date.plusDays(1) } new DemandedLevelsChanged(new RefNoId(refNo), results) } + ReviewRequested reviewRequest(ReviewNeeded... reviews) { + new ReviewRequested(new RefNoId(refNo), reviews as List) + } + + ReviewNeeded review(LocalDate date, + long previousDocumented, + long strongAdjustment, + long newDocumented) { + new ReviewNeeded( + new DailyId(refNo, date), + Demand.of(previousDocumented), + Demand.of(strongAdjustment), + Demand.of(newDocumented)) + } + void clearUnitOfWork() { unitOfWork.@changes.clear() unitOfWork.@reviews.clear() diff --git a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ProductDemandTrait.groovy b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ProductDemandTrait.groovy new file mode 100644 index 0000000..94b8978 --- /dev/null +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ProductDemandTrait.groovy @@ -0,0 +1,48 @@ +package pl.com.bottega.factory.demand.forecasting + +import java.time.LocalDate + +import static ReviewRequested.ReviewNeeded +import static pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged +import static pl.com.bottega.factory.demand.forecasting.DemandEvents.ReviewRequested + +trait ProductDemandTrait { + + ProductDemandBuilder builder + + ProductDemand demanded(long ... levels) { + builder.demand(levels).build() + } + + ProductDemandBuilder demand(long ... levels) { + builder.demand(levels) + } + + Document document(LocalDate date, long ... levels) { + builder.document(date, levels) + } + + AdjustDemand adjustments(Map map) { + builder.adjustDemand(map) + } + + DemandedLevelsChanged levelChanged(List... changes) { + builder.levelChanged(changes) + } + + List notChanged() { + [] + } + + ReviewRequested reviewRequest(ReviewNeeded... reviews) { + builder.reviewRequest(reviews) + } + + ReviewNeeded review( + LocalDate date, + long previousDocumented, + long strongAdjustment, + long newDocumented) { + return builder.review(date, previousDocumented, strongAdjustment, newDocumented) + } +} \ No newline at end of file diff --git a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ReviewPolicySpec.groovy b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ReviewPolicySpec.groovy new file mode 100644 index 0000000..2caa794 --- /dev/null +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ReviewPolicySpec.groovy @@ -0,0 +1,44 @@ +package pl.com.bottega.factory.demand.forecasting + +import spock.lang.Specification + +import static pl.com.bottega.factory.demand.forecasting.Adjustment.strong +import static pl.com.bottega.factory.demand.forecasting.Adjustment.weak +import static pl.com.bottega.factory.demand.forecasting.Demand.of + +class ReviewPolicySpec extends Specification { + + def "'basic review policy' requires review only after strong adjustment \ + when new document doesn't match neither previous document nor adjustment"() { + given: + def policy = ReviewPolicy.BASIC + + expect: + policy.reviewNeeded( + previousDocument, + adjustment, + newDocument + ) == review + + where: + previousDocument | adjustment | newDocument || review + of(1000) | strong(of(1000)) | of(1000) || notNeeded() + of(1000) | strong(of(2000)) | of(2000) || notNeeded() + of(1000) | strong(of(2000)) | of(1000) || notNeeded() + of(1000) | strong(of(2000)) | of(1500) || needed() + of(1000) | strong(of(2000)) | of(0) || needed() + of(1000) | weak(of(1000)) | of(1000) || notNeeded() + of(1000) | weak(of(2000)) | of(2000) || notNeeded() + of(1000) | weak(of(2000)) | of(1000) || notNeeded() + of(1000) | weak(of(2000)) | of(1500) || notNeeded() + of(1000) | weak(of(2000)) | of(0) || notNeeded() + } + + private static boolean needed() { + true + } + + private static boolean notNeeded() { + false + } +} diff --git a/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ReviewProcessingSpec.groovy b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ReviewProcessingSpec.groovy new file mode 100644 index 0000000..de3eacd --- /dev/null +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ReviewProcessingSpec.groovy @@ -0,0 +1,92 @@ +package pl.com.bottega.factory.demand.forecasting + +import spock.lang.Specification + +import java.time.LocalDate + +import static pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged +import static pl.com.bottega.factory.demand.forecasting.ReviewDecision.* + +class ReviewProcessingSpec extends Specification implements ProductDemandTrait { + + def events = Mock(DemandEvents) + + void setup() { + builder = new ProductDemandBuilder(events: events) + } + + def "Review requested"() { + given: + def today = LocalDate.now(builder.clock) + def tomorrow = today.plusDays(1) + def demand = demand(0, 0) + .stronglyAdjusted((tomorrow): 3500) + .build() + + when: + demand.process(document(today, 0, 2800)) + + then: + 1 * events.emit(reviewRequest(review(tomorrow, 0, 3500, 2800))) + } + + def "decision to 'ignore'"() { + given: + def today = LocalDate.now(builder.clock) + def tomorrow = today.plusDays(1) + def demand = demand(0, 2800) + .stronglyAdjusted((tomorrow): 3500) + .build() + + when: + demand.review(review(tomorrow, 0, 3500, 2800), IGNORE) + + then: + 0 * events.emit(_ as DemandedLevelsChanged) + } + + def "decision to 'pick new'"() { + given: + def today = LocalDate.now(builder.clock) + def tomorrow = today.plusDays(1) + def demand = demand(0, 2800) + .stronglyAdjusted((tomorrow): 3500) + .build() + + when: + demand.review(review(tomorrow, 0, 3500, 2800), PICK_NEW) + + then: + 1 * events.emit(levelChanged([], [3500, 2800])) + } + + def "decision to 'pick previous'"() { + given: + def today = LocalDate.now(builder.clock) + def tomorrow = today.plusDays(1) + def demand = demand(0, 2800) + .stronglyAdjusted((tomorrow): 3500) + .build() + + when: + demand.review(review(tomorrow, 0, 3500, 2800), PICK_PREVIOUS) + + then: + 1 * events.emit(levelChanged([], [3500, 0])) + } + + def "decision to 'make adjustment weak'"() { + given: + def today = LocalDate.now(builder.clock) + def tomorrow = today.plusDays(1) + def demand = demand(0, 2800) + .stronglyAdjusted((tomorrow): 3500) + .build() + + when: + demand.review(review(tomorrow, 0, 3500, 2800), MAKE_ADJUSTMENT_WEAK) + + then: + 0 * events.emit(_ as DemandedLevelsChanged) + } +} diff --git a/shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DailyId.java b/shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DailyId.java index f7b1582..c3bba38 100644 --- a/shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DailyId.java +++ b/shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DailyId.java @@ -3,12 +3,14 @@ package pl.com.bottega.factory.demand.forecasting; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.ToString; import java.time.LocalDate; @Getter @AllArgsConstructor @EqualsAndHashCode +@ToString public class DailyId { private final String refNo; private final LocalDate date; diff --git a/shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandEvents.java b/shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandEvents.java index 1f2410e..aaeac22 100644 --- a/shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandEvents.java +++ b/shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandEvents.java @@ -3,30 +3,57 @@ package pl.com.bottega.factory.demand.forecasting; import lombok.Value; import pl.com.bottega.factory.product.management.RefNoId; +import java.time.LocalDate; import java.util.Collections; +import java.util.List; import java.util.Map; public interface DemandEvents { void emit(DemandedLevelsChanged event); + void emit(ReviewRequested event); + @Value class DemandedLevelsChanged { - RefNoId id; + RefNoId refNo; Map results; - public DemandedLevelsChanged(RefNoId id, Map results) { - this.id = id; + public DemandedLevelsChanged(RefNoId refNo, Map results) { + this.refNo = refNo; this.results = Collections.unmodifiableMap(results); } - public RefNoId getRefNo() { - return id; - } - @Value public static class Change { Demand previous; Demand current; } } + + @Value + class ReviewRequested { + RefNoId refNo; + List reviews; + + public ReviewRequested(RefNoId refNo, List reviews) { + this.refNo = refNo; + this.reviews = Collections.unmodifiableList(reviews); + } + + @Value + public static class ReviewNeeded { + DailyId id; + Demand previousDocumented; + Demand adjustment; + Demand newDocumented; + + public String getRefNo() { + return id.getRefNo(); + } + + public LocalDate getDate() { + return id.getDate(); + } + } + } } diff --git a/shared-kernel-model/src/main/java/pl/com/bottega/factory/product/management/RefNoId.java b/shared-kernel-model/src/main/java/pl/com/bottega/factory/product/management/RefNoId.java index ac3fdd8..3d3c118 100644 --- a/shared-kernel-model/src/main/java/pl/com/bottega/factory/product/management/RefNoId.java +++ b/shared-kernel-model/src/main/java/pl/com/bottega/factory/product/management/RefNoId.java @@ -3,10 +3,12 @@ package pl.com.bottega.factory.product.management; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.ToString; @Getter @AllArgsConstructor @EqualsAndHashCode +@ToString public class RefNoId { private final String refNo; }