demand changes review processing

This commit is contained in:
Michał Michaluk
2017-12-12 23:23:15 +01:00
parent 277f5f9822
commit c6e0b9baf8
27 changed files with 550 additions and 110 deletions

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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<ReviewNeeded, Demand> 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;
}
}

View File

@@ -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
);
}

View File

@@ -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<DailyId, DemandEvents.DemandedLevelsChanged.Change> changes = new HashMap<>();
List<DailyDemand.ReviewRequest> reviews = new LinkedList<>();
Map<DailyId, DemandedLevelsChanged.Change> changes = new HashMap<>();
List<ReviewNeeded> reviews = new LinkedList<>();
List<DailyDemand.DemandUpdated> updates = new LinkedList<>();
boolean anyChanges() {
return !changes.isEmpty();
}
Map<DailyId, DemandEvents.DemandedLevelsChanged.Change> changes() {
Map<DailyId, DemandedLevelsChanged.Change> changes() {
return Collections.unmodifiableMap(changes);
}
@@ -22,7 +25,7 @@ class UnitOfWork implements DailyDemand.Events {
return !reviews.isEmpty();
}
List<DailyDemand.ReviewRequest> reviews() {
List<ReviewNeeded> 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);
}

View File

@@ -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)
)
}
}

View File

@@ -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<LocalDate, Long> map) {
builder.adjustDemand(map)
}
DemandedLevelsChanged levelChanged(List<Long>... changes) {
builder.levelChanged(changes)
}
List<Long> notChanged() {
[]
}
}

View File

@@ -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
}
}

View File

@@ -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<Long>... changes) {
builder.levelChanged(changes)
}
List<Long> notChanged() {
[]
}
}

View File

@@ -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)
}
}

View File

@@ -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<LocalDate, Long> adjustments) {
adjustments.each { date, level ->
demands.adjusted(date, level)
}
this
}
def stronglyAdjusted(Map<LocalDate, Long> 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<LocalDate, Demand> results = new TreeMap<>()
for (def level : levels) {
@@ -34,15 +54,15 @@ class ProductDemandBuilder {
new Document(created, refNo, results)
}
AdjustDemand adjustDemand(Map<LocalDate, Long> adjustments) {
def adjustDemand(Map<LocalDate, Long> adjustments) {
Map<LocalDate, Adjustment> 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<Long>... changes) {
def levelChanged(List<Long>... changes) {
def date = LocalDate.now(clock)
Map<DailyId, Change> 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()

View File

@@ -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<LocalDate, Long> map) {
builder.adjustDemand(map)
}
DemandedLevelsChanged levelChanged(List<Long>... changes) {
builder.levelChanged(changes)
}
List<Long> 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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}