demand changes review processing
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DemandReviewEntity, Long> {
|
||||
@RestResource(path = "refNos", rel = "refNos")
|
||||
List<DemandReviewEntity> findByRefNoAndDecisionIsNull(String refNo);
|
||||
|
||||
@RestResource(exported = false)
|
||||
void deleteByCleanAfterGreaterThanEqual(LocalDate date);
|
||||
}
|
||||
@@ -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<ReviewNeeded> {
|
||||
public ReviewAsJson() {
|
||||
super(ReviewNeeded.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()); }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
[]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
[]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DailyId, Change> results;
|
||||
|
||||
public DemandedLevelsChanged(RefNoId id, Map<DailyId, Change> results) {
|
||||
this.id = id;
|
||||
public DemandedLevelsChanged(RefNoId refNo, Map<DailyId, Change> 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<ReviewNeeded> reviews;
|
||||
|
||||
public ReviewRequested(RefNoId refNo, List<ReviewNeeded> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user