From b4c706b1176e09682fa254e6a95d7e90a46866eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Michaluk?= Date: Mon, 4 Dec 2017 09:00:27 +0100 Subject: [PATCH] domain model and empty database --- .gitattributes | 7 + .gitignore | 88 +++++++++ README.md | 2 +- database/Dockerfile | 17 ++ database/initdb.sql | 4 + database/schema/db.changelog.xml | 8 + database/schema/product-management.sql | 3 + database/start-and-migrate.sh | 173 +++++++++++++++++ demand-forecasting-model/pom.xml | 93 +++++++++ .../planning/DeliveriesSuggestion.java | 28 +++ .../factory/delivery/planning/Delivery.java | 12 ++ .../planning/DeliveryAutoPlanner.java | 19 ++ .../demand/forecasting/AdjustDemand.java | 26 +++ .../demand/forecasting/Adjustment.java | 22 +++ .../demand/forecasting/DailyDemand.java | 116 ++++++++++++ .../factory/demand/forecasting/Demands.java | 65 +++++++ .../factory/demand/forecasting/Document.java | 30 +++ .../demand/forecasting/ProductDemand.java | 68 +++++++ .../forecasting/DailyDemandBuilder.groovy | 87 +++++++++ .../forecasting/DemandAdjustmentSpec.groovy | 101 ++++++++++ .../forecasting/DemandsRepositoryFake.groovy | 36 ++++ .../forecasting/DocumentProcessingSpec.groovy | 101 ++++++++++ .../KeepingDailyDemandsSpec.groovy | 98 ++++++++++ .../forecasting/ProductDemandBuilder.groovy | 57 ++++++ .../demand/forecasting/DemandsFake.java | 20 ++ .../scenarios/DemandAdjustements.feature | 96 ++++++++++ .../scenarios/ProductionPlanning.feature | 90 +++++++++ .../resources/scenarios/domain-stories.txt | 81 ++++++++ docker-compose.yml | 13 ++ pom.xml | 20 ++ shared-kernel-model/pom.xml | 82 ++++++++ .../factory/demand/forecasting/DailyId.java | 15 ++ .../factory/demand/forecasting/Demand.java | 25 +++ .../demand/forecasting/DemandEvents.java | 28 +++ .../factory/product/management/RefNoId.java | 12 ++ shortages-prediction-model/pom.xml | 87 +++++++++ .../shortages/prediction/Configuration.java | 8 + .../shortages/prediction/Shortages.java | 67 +++++++ .../prediction/calculation/CurrentStock.java | 9 + .../prediction/calculation/Demands.java | 16 ++ .../prediction/calculation/Forecast.java | 42 ++++ .../prediction/calculation/Forecasts.java | 5 + .../calculation/ProductionForecast.java | 55 ++++++ .../calculation/ProductionOutputs.java | 19 ++ .../prediction/monitoring/NewShortage.java | 16 ++ .../monitoring/ShortageDiffPolicy.java | 10 + .../prediction/monitoring/ShortageEvents.java | 7 + .../monitoring/ShortagePredictionProcess.java | 57 ++++++ .../ShortagePredictionProcessRepository.java | 12 ++ .../prediction/monitoring/ShortageSolved.java | 8 + .../notification/NotificationOfShortage.java | 67 +++++++ .../notification/Notifications.java | 14 ++ .../prediction/notification/QualityTasks.java | 8 + .../RecoveryTaskPriorityChangePolicy.java | 27 +++ .../ShortagesCalculationAlgorithmSpec.groovy | 171 +++++++++++++++++ .../ShortagesCalculationAssembler.groovy | 4 + .../ShortagesCalculationAssemblerTrait.groovy | 67 +++++++ .../ShortagesCalculationExamplesSpec.groovy | 115 +++++++++++ .../prediction/calculation/TimeGrammar.groovy | 21 ++ .../monitoring/InMemoryConfiguration.groovy | 12 ++ .../monitoring/ShortageDiffPolicySpec.groovy | 128 +++++++++++++ .../ShortagePredictionProcessSpec.groovy | 179 ++++++++++++++++++ .../NotificationOfShortageSpec.groovy | 135 +++++++++++++ ...ecoveryTaskPriorityChangePolicySpec.groovy | 76 ++++++++ 64 files changed, 3184 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 database/Dockerfile create mode 100644 database/initdb.sql create mode 100644 database/schema/db.changelog.xml create mode 100644 database/schema/product-management.sql create mode 100644 database/start-and-migrate.sh create mode 100644 demand-forecasting-model/pom.xml create mode 100644 demand-forecasting-model/src/main/java/pl/com/bottega/factory/delivery/planning/DeliveriesSuggestion.java create mode 100644 demand-forecasting-model/src/main/java/pl/com/bottega/factory/delivery/planning/Delivery.java create mode 100644 demand-forecasting-model/src/main/java/pl/com/bottega/factory/delivery/planning/DeliveryAutoPlanner.java create mode 100644 demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/AdjustDemand.java create mode 100644 demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Adjustment.java create mode 100644 demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DailyDemand.java create mode 100644 demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Demands.java create mode 100644 demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Document.java create mode 100644 demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/ProductDemand.java create mode 100644 demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DailyDemandBuilder.groovy create mode 100644 demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandAdjustmentSpec.groovy create mode 100644 demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandsRepositoryFake.groovy create mode 100644 demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DocumentProcessingSpec.groovy create mode 100644 demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/KeepingDailyDemandsSpec.groovy create mode 100644 demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ProductDemandBuilder.groovy create mode 100644 demand-forecasting-model/src/test/java/pl/com/bottega/factory/demand/forecasting/DemandsFake.java create mode 100644 demand-forecasting-model/src/test/resources/scenarios/DemandAdjustements.feature create mode 100644 demand-forecasting-model/src/test/resources/scenarios/ProductionPlanning.feature create mode 100644 demand-forecasting-model/src/test/resources/scenarios/domain-stories.txt create mode 100644 docker-compose.yml create mode 100644 pom.xml create mode 100644 shared-kernel-model/pom.xml create mode 100644 shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DailyId.java create mode 100644 shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Demand.java create mode 100644 shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandEvents.java create mode 100644 shared-kernel-model/src/main/java/pl/com/bottega/factory/product/management/RefNoId.java create mode 100644 shortages-prediction-model/pom.xml create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/Configuration.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/Shortages.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/CurrentStock.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/Demands.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/Forecast.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/Forecasts.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/ProductionForecast.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/ProductionOutputs.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/NewShortage.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageDiffPolicy.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageEvents.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortagePredictionProcess.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortagePredictionProcessRepository.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageSolved.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/NotificationOfShortage.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/Notifications.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/QualityTasks.java create mode 100644 shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/RecoveryTaskPriorityChangePolicy.java create mode 100644 shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationAlgorithmSpec.groovy create mode 100644 shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationAssembler.groovy create mode 100644 shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationAssemblerTrait.groovy create mode 100644 shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationExamplesSpec.groovy create mode 100644 shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/TimeGrammar.groovy create mode 100644 shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/monitoring/InMemoryConfiguration.groovy create mode 100644 shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageDiffPolicySpec.groovy create mode 100644 shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/monitoring/ShortagePredictionProcessSpec.groovy create mode 100644 shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/notification/NotificationOfShortageSpec.groovy create mode 100644 shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/notification/RecoveryTaskPriorityChangePolicySpec.groovy diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..53d70c3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ + +# Set default behaviour, in case users don't have core.autocrlf set. +* text=auto + +# Denote all files that are truly binary and should not be modified. +*.png binary +*.jpg binary \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7335fff --- /dev/null +++ b/.gitignore @@ -0,0 +1,88 @@ + +## maven +target/ +target-test/ + + +## linux +.* +!.git* +*~ + + +## windows +# Windows image file caches +Thumbs.db +ehthumbs.db +# Folder config file +Desktop.ini +# Recycle Bin used on file shares +$RECYCLE.BIN/ + + +## osx +.DS_Store +.AppleDouble +.LSOverride +Icon +# Thumbnails +._* +# Files that might appear on external disk +.Spotlight-V100 +.Trashes + + +## eclipse +*.pydevproject +.project +.metadata +*.tmp +*.bak +*.swp +*~.nib +local.properties +.classpath +.settings/ +.loadpath +# External tool builders +.externalToolBuilders/ +# Locally stored "Eclipse launch configurations" +*.launch +# CDT-specific +.cproject +# PDT-specific +.buildpath + + +## intelij +*.iml +*.ipr +*.iws +.idea/ + + +## netbeans +nbactions.xml +nb-configuration.xml + + +## emacs +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +.elc +auto-save-list +tramp +.\#* +# Org-mode +.org-id-locations +*_archive + + +## vim +.*.s[a-w][a-z] +*.un~ +Session.vim +.netrwhist +*~ diff --git a/README.md b/README.md index 37195db..21e5686 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# factory \ No newline at end of file +# production \ No newline at end of file diff --git a/database/Dockerfile b/database/Dockerfile new file mode 100644 index 0000000..c5c42bf --- /dev/null +++ b/database/Dockerfile @@ -0,0 +1,17 @@ +FROM postgres:10 +MAINTAINER Michał Michaluk + +RUN apt-get update -y +RUN apt-get install openjdk-8-jre-headless -y + +ADD http://central.maven.org/maven2/org/liquibase/liquibase-core/3.4.2/liquibase-core-3.4.2.jar /lib/liquibase.jar +ADD http://central.maven.org/maven2/org/postgresql/postgresql/9.4.1212/postgresql-9.4.1212.jar /lib/postgresql.jar +RUN chmod a+r /lib/liquibase.jar +RUN chmod a+r /lib/postgresql.jar + +ADD schema /schema +ADD initdb.sql /docker-entrypoint-initdb.d/ +ADD start-and-migrate.sh / +RUN chmod +x /start-and-migrate.sh + +CMD /start-and-migrate.sh postgres diff --git a/database/initdb.sql b/database/initdb.sql new file mode 100644 index 0000000..e14b9f9 --- /dev/null +++ b/database/initdb.sql @@ -0,0 +1,4 @@ + +--create schema demands authorization postgres; +--create schema shortages authorization postgres; +--create schema products authorization postgres; diff --git a/database/schema/db.changelog.xml b/database/schema/db.changelog.xml new file mode 100644 index 0000000..ccc937e --- /dev/null +++ b/database/schema/db.changelog.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/database/schema/product-management.sql b/database/schema/product-management.sql new file mode 100644 index 0000000..ded654b --- /dev/null +++ b/database/schema/product-management.sql @@ -0,0 +1,3 @@ +--liquibase formatted sql + +--changeset michaluk.michal:1.init diff --git a/database/start-and-migrate.sh b/database/start-and-migrate.sh new file mode 100644 index 0000000..61592f2 --- /dev/null +++ b/database/start-and-migrate.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +set -e + +# usage: file_env VAR [DEFAULT] +# ie: file_env 'XYZ_DB_PASSWORD' 'example' +# (will allow for "$XYZ_DB_PASSWORD_FILE" to fill in the value of +# "$XYZ_DB_PASSWORD" from a file, especially for Docker's secrets feature) +file_env() { + local var="$1" + local fileVar="${var}_FILE" + local def="${2:-}" + if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then + echo >&2 "error: both $var and $fileVar are set (but are exclusive)" + exit 1 + fi + local val="$def" + if [ "${!var:-}" ]; then + val="${!var}" + elif [ "${!fileVar:-}" ]; then + val="$(< "${!fileVar}")" + fi + export "$var"="$val" + unset "$fileVar" +} + +if [ "${1:0:1}" = '-' ]; then + set -- postgres "$@" +fi + +# allow the container to be started with `--user` +if [ "$1" = 'postgres' ] && [ "$(id -u)" = '0' ]; then + mkdir -p "$PGDATA" + chown -R postgres "$PGDATA" + chmod 700 "$PGDATA" + + mkdir -p /var/run/postgresql + chown -R postgres /var/run/postgresql + chmod 775 /var/run/postgresql + + # Create the transaction log directory before initdb is run (below) so the directory is owned by the correct user + if [ "$POSTGRES_INITDB_XLOGDIR" ]; then + mkdir -p "$POSTGRES_INITDB_XLOGDIR" + chown -R postgres "$POSTGRES_INITDB_XLOGDIR" + chmod 700 "$POSTGRES_INITDB_XLOGDIR" + fi + + exec gosu postgres "$BASH_SOURCE" "$@" +fi + +if [ "$1" = 'postgres' ]; then + mkdir -p "$PGDATA" + chown -R "$(id -u)" "$PGDATA" 2>/dev/null || : + chmod 700 "$PGDATA" 2>/dev/null || : + + # look specifically for PG_VERSION, as it is expected in the DB dir + if [ ! -s "$PGDATA/PG_VERSION" ]; then + file_env 'POSTGRES_INITDB_ARGS' + if [ "$POSTGRES_INITDB_XLOGDIR" ]; then + export POSTGRES_INITDB_ARGS="$POSTGRES_INITDB_ARGS --xlogdir $POSTGRES_INITDB_XLOGDIR" + fi + eval "initdb --username=postgres $POSTGRES_INITDB_ARGS" + + # check password first so we can output the warning before postgres + # messes it up + file_env 'POSTGRES_PASSWORD' + if [ "$POSTGRES_PASSWORD" ]; then + pass="PASSWORD '$POSTGRES_PASSWORD'" + authMethod=md5 + else + # The - option suppresses leading tabs but *not* spaces. :) + cat >&2 <<-'EOWARN' + **************************************************** + WARNING: No password has been set for the database. + This will allow anyone with access to the + Postgres port to access your database. In + Docker's default configuration, this is + effectively any other container on the same + system. + + Use "-e POSTGRES_PASSWORD=password" to set + it in "docker run". + **************************************************** + EOWARN + + pass= + authMethod=trust + fi + + { + echo + echo "host all all all $authMethod" + } >> "$PGDATA/pg_hba.conf" + + # internal start of server in order to allow set-up using psql-client + # does not listen on external TCP/IP and waits until start finishes + PGUSER="${PGUSER:-postgres}" \ + pg_ctl -D "$PGDATA" \ + -o "-c listen_addresses='localhost'" \ + -w start + + file_env 'POSTGRES_USER' 'postgres' + file_env 'POSTGRES_DB' "$POSTGRES_USER" + + psql=( psql -v ON_ERROR_STOP=1 ) + + if [ "$POSTGRES_DB" != 'postgres' ]; then + "${psql[@]}" --username postgres <<-EOSQL + CREATE DATABASE "$POSTGRES_DB" ; + EOSQL + echo + fi + + if [ "$POSTGRES_USER" = 'postgres' ]; then + op='ALTER' + else + op='CREATE' + fi + "${psql[@]}" --username postgres <<-EOSQL + $op USER "$POSTGRES_USER" WITH SUPERUSER $pass ; + EOSQL + echo + + psql+=( --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" ) + + echo + for f in /docker-entrypoint-initdb.d/*; do + case "$f" in + *.sh) echo "$0: running $f"; . "$f" ;; + *.sql) echo "$0: running $f"; "${psql[@]}" -f "$f"; echo ;; + *.sql.gz) echo "$0: running $f"; gunzip -c "$f" | "${psql[@]}"; echo ;; + *) echo "$0: ignoring $f" ;; + esac + echo + done + + PGUSER="${PGUSER:-postgres}" \ + pg_ctl -D "$PGDATA" -m fast -w stop + + echo + echo 'PostgreSQL init process complete; ready for start up.' + echo + fi +fi + +# migrate schema with liquibase if anything in /schema has changed +SCHEMASUM=`find /schema/ -type f | xargs cat | md5sum | cut -d ' ' -f1` +if [ ! -f $PGDATA/schema.md5 ] || [[ $(< $PGDATA/schema.md5) != "$SCHEMASUM" ]]; then + + PGUSER="${PGUSER:-postgres}" \ + pg_ctl -D "$PGDATA" \ + -o "-c listen_addresses='localhost'" \ + -w start + + echo + echo 'Starting Schema Migration' + + java -jar /lib/liquibase.jar \ + --classpath=/lib/postgresql.jar \ + --changeLogFile=/schema/db.changelog.xml \ + --driver=org.postgresql.Driver \ + --url="jdbc:postgresql://localhost:5432/postgres" \ + --username="postgres" \ + --password="" \ + update + + echo + + PGUSER="${PGUSER:-postgres}" \ + pg_ctl -D "$PGDATA" -m fast -w stop + echo "$SCHEMASUM" > $PGDATA/schema.md5 +fi + +exec "$@" diff --git a/demand-forecasting-model/pom.xml b/demand-forecasting-model/pom.xml new file mode 100644 index 0000000..e064ea9 --- /dev/null +++ b/demand-forecasting-model/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + pl.com.bottega + demand-forecasting-model + jar + 1.0-SNAPSHOT + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.7.0 + + 1.8 + 1.8 + -parameters + + + + org.codehaus.gmavenplus + gmavenplus-plugin + 1.6 + + + + compileTests + + + + + + maven-surefire-plugin + 2.20.1 + + false + + **/*Spec.java + **/*Test.java + + + + + + + + + pl.com.bottega + shared-kernel-model + 1.0-SNAPSHOT + + + org.projectlombok + lombok + 1.16.18 + provided + + + junit + junit + 4.12 + test + + + org.assertj + assertj-core + 3.8.0 + test + + + info.cukes + cucumber-java + 1.2.5 + test + + + info.cukes + cucumber-core + 1.2.5 + test + + + org.spockframework + spock-core + 1.1-groovy-2.4 + test + + + \ No newline at end of file diff --git a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/delivery/planning/DeliveriesSuggestion.java b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/delivery/planning/DeliveriesSuggestion.java new file mode 100644 index 0000000..8124d69 --- /dev/null +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/delivery/planning/DeliveriesSuggestion.java @@ -0,0 +1,28 @@ +package pl.com.bottega.factory.delivery.planning; + +import pl.com.bottega.factory.demand.forecasting.Demand; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.stream.Stream; + +interface DeliveriesSuggestion { + + DeliveriesSuggestion DUMMY = (refNo, date, demand) -> + Stream.of(new Delivery(refNo, date.atStartOfDay(), demand.getLevel())); + + static DeliveriesSuggestion allAtTime(LocalTime time) { + return (refNo, date, demand) -> + Stream.of(new Delivery(refNo, date.atTime(time), demand.getLevel())); + } + + static DeliveriesSuggestion twoAtTimes(LocalTime first, LocalTime second) { + return (refNo, date, demand) -> + Stream.of( + new Delivery(refNo, date.atTime(first), demand.getLevel() / 2), + new Delivery(refNo, date.atTime(second), demand.getLevel() - (demand.getLevel() / 2)) + ); + } + + Stream deliveriesFor(String refNo, LocalDate date, Demand demand); +} diff --git a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/delivery/planning/Delivery.java b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/delivery/planning/Delivery.java new file mode 100644 index 0000000..ebbf071 --- /dev/null +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/delivery/planning/Delivery.java @@ -0,0 +1,12 @@ +package pl.com.bottega.factory.delivery.planning; + +import lombok.Value; + +import java.time.LocalDateTime; + +@Value +public class Delivery { + String refNo; + LocalDateTime time; + long level; +} diff --git a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/delivery/planning/DeliveryAutoPlanner.java b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/delivery/planning/DeliveryAutoPlanner.java new file mode 100644 index 0000000..b78fb52 --- /dev/null +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/delivery/planning/DeliveryAutoPlanner.java @@ -0,0 +1,19 @@ +package pl.com.bottega.factory.delivery.planning; + +import pl.com.bottega.factory.demand.forecasting.Demand; +import lombok.AllArgsConstructor; + +import java.time.LocalDate; +import java.util.Map; +import java.util.stream.Stream; + +@AllArgsConstructor +public class DeliveryAutoPlanner { + private String refNo; + private Map policies; + + public Stream propose(LocalDate date, Demand demand) { + return policies.getOrDefault(demand.getSchema(), DeliveriesSuggestion.DUMMY) + .deliveriesFor(refNo, date, demand); + } +} diff --git a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/AdjustDemand.java b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/AdjustDemand.java new file mode 100644 index 0000000..a23fa25 --- /dev/null +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/AdjustDemand.java @@ -0,0 +1,26 @@ +package pl.com.bottega.factory.demand.forecasting; + +import lombok.Value; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.Map; +import java.util.function.BiConsumer; + +@Value +public class AdjustDemand { + String refNo; + Map adjustments; + + public AdjustDemand(String refNo, + Map adjustments) { + this.refNo = refNo; + this.adjustments = Collections.unmodifiableMap(adjustments); + } + + public void forEachStartingFrom(LocalDate date, BiConsumer f) { + adjustments.entrySet().stream() + .filter(e -> !e.getKey().isBefore(date)) + .forEach(e -> f.accept(e.getKey(), e.getValue())); + } +} 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 new file mode 100644 index 0000000..275a691 --- /dev/null +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Adjustment.java @@ -0,0 +1,22 @@ +package pl.com.bottega.factory.demand.forecasting; + +import lombok.Value; + +@Value +class Adjustment { + + Demand demand; + boolean strong; + + static Adjustment strong(Demand demand) { + return new Adjustment(demand, true); + } + + static Adjustment week(Demand demand) { + return new Adjustment(demand, false); + } + + static boolean isStrong(Adjustment adjustment) { + return adjustment != null && adjustment.strong; + } +} 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 new file mode 100644 index 0000000..acb6e45 --- /dev/null +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DailyDemand.java @@ -0,0 +1,116 @@ +package pl.com.bottega.factory.demand.forecasting; + +import pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged.Change; +import lombok.Value; + +import java.util.Objects; +import java.util.Optional; + +class DailyDemand { + + private final DailyId id; + private final Events events; + + private Demand documented; + private Adjustment adjustment; + + interface Events { + void emit(LevelChanged event); + + void emit(ReviewRequest event); + + void emit(DemandUpdated event); + } + + DailyDemand(DailyId id, Events events, + Demand documented, Adjustment adjustment) { + this.id = id; + this.events = events; + this.documented = Optional.ofNullable(documented) + .orElse(Demand.nothingDemanded()); + this.adjustment = adjustment; + } + + void adjust(Adjustment adjustment) { + State state = state(); + this.adjustment = adjustment; + + if (state.updated()) { + events.emit(new DemandUpdated(id, documented, adjustment)); + } + if (state.levelChanged()) { + events.emit(new LevelChanged(id, state.getLevelChange())); + } + } + + void update(Demand documented) { + State state = state(); + if (!Adjustment.isStrong(this.adjustment)) { + this.adjustment = null; + } + this.documented = documented; + + if (state.updated()) { + events.emit(new DemandUpdated(id, documented, adjustment)); + } + if (state.levelChanged()) { + events.emit(new LevelChanged(id, state.getLevelChange())); + } + } + + Demand getLevel() { + return Optional.ofNullable(adjustment) + .map(Adjustment::getDemand) + .orElse(documented); + } + + @Value + static class LevelChanged { + DailyId id; + Change change; + } + + @Value + static class ReviewRequest { + DailyId id; + Demand previousDocumented; + Demand strongAdjustment; + Demand newDocumented; + } + + @Value + static class DemandUpdated { + DailyId id; + Demand documented; + Adjustment adjustment; + } + + private State state() { + return new State(); + } + + private class State { + final Demand documented; + final Adjustment adjustment; + final Demand level; + + State() { + this.documented = DailyDemand.this.documented; + this.adjustment = DailyDemand.this.adjustment; + this.level = getLevel(); + } + + boolean updated() { + return !Objects.equals(this.documented, DailyDemand.this.documented) + || !Objects.equals(this.adjustment, DailyDemand.this.adjustment); + } + + Change getLevelChange() { + return new Change(level, getLevel()); + } + + boolean levelChanged() { + return !level.equals(getLevel()); + } + } +} diff --git a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Demands.java b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Demands.java new file mode 100644 index 0000000..d550721 --- /dev/null +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Demands.java @@ -0,0 +1,65 @@ +package pl.com.bottega.factory.demand.forecasting; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static java.util.Collections.unmodifiableList; + +class Demands implements ProductDemand.Demands, DailyDemand.Events { + final Map fetched = new HashMap<>(); + final List changes = new ArrayList<>(); + final List updates = new ArrayList<>(); + final List warnings = new ArrayList<>(); + Function fetch; + + @Override + public DailyDemand get(LocalDate date) { + return fetched.computeIfAbsent(date, fetch); + } + + @Override + public List getChanges() { + return unmodifiableList(changes); + } + + @Override + public List getUpdates() { + return unmodifiableList(updates); + } + + @Override + public List getReviewRequests() { + return unmodifiableList(warnings); + } + + @Override + public void emit(DailyDemand.LevelChanged event) { + changes.add(event); + } + + @Override + public void emit(DailyDemand.ReviewRequest event) { + warnings.add(event); + } + + @Override + public void emit(DailyDemand.DemandUpdated event) { + updates.add(event); + } + + @Override + public boolean anyChanges() { + return !getChanges().isEmpty(); + } + + @Override + public Map changes() { + return getChanges().stream().collect(Collectors.toMap( + DailyDemand.LevelChanged::getId, DailyDemand.LevelChanged::getChange)); + } +} diff --git a/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Document.java b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Document.java new file mode 100644 index 0000000..80778d6 --- /dev/null +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Document.java @@ -0,0 +1,30 @@ +package pl.com.bottega.factory.demand.forecasting; + +import lombok.Value; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.Collections; +import java.util.SortedMap; +import java.util.function.BiConsumer; + +@Value +public class Document { + + Instant created; + String refNo; + SortedMap demands; + + public Document(Instant created, String refNo, SortedMap demands) { + this.created = created; + this.refNo = refNo; + this.demands = Collections.unmodifiableSortedMap(demands); + } + + public void forEachStartingFrom(LocalDate date, BiConsumer f) { + demands.entrySet().stream() + .filter(e -> !e.getKey().isBefore(date)) + .forEach(e -> f.accept(e.getKey(), e.getValue())); + } +} + 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 new file mode 100644 index 0000000..1af0071 --- /dev/null +++ b/demand-forecasting-model/src/main/java/pl/com/bottega/factory/demand/forecasting/ProductDemand.java @@ -0,0 +1,68 @@ +package pl.com.bottega.factory.demand.forecasting; + +import lombok.AllArgsConstructor; +import pl.com.bottega.factory.demand.forecasting.DailyDemand.DemandUpdated; +import pl.com.bottega.factory.demand.forecasting.DailyDemand.LevelChanged; +import pl.com.bottega.factory.demand.forecasting.DailyDemand.ReviewRequest; +import pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged; +import pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged.Change; +import pl.com.bottega.factory.product.management.RefNoId; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@AllArgsConstructor +class ProductDemand { + + final RefNoId id; + final Demands demands; + + final Clock clock; + final DemandEvents events; + + interface Demands { + DailyDemand get(LocalDate date); + + List getChanges(); + + List getUpdates(); + + List getReviewRequests(); + + boolean anyChanges(); + + Map changes(); + } + + void adjust(AdjustDemand adjustDemand) { + LocalDate today = LocalDate.now(clock); + + adjustDemand.forEachStartingFrom(today, this::adjustDaily); + + if (demands.anyChanges()) { + events.emit(new DemandedLevelsChanged(id, demands.changes())); + } + } + + void process(Document document) { + LocalDate today = LocalDate.now(clock); + + document.forEachStartingFrom(today, this::updateDaily); + + if (demands.anyChanges()) { + events.emit(new DemandedLevelsChanged(id, demands.changes())); + } + } + + private void adjustDaily(LocalDate date, Adjustment adjustment) { + DailyDemand demand = demands.get(date); + demand.adjust(adjustment); + } + + private void updateDaily(LocalDate date, Demand demand) { + DailyDemand daily = demands.get(date); + daily.update(demand); + } +} 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 new file mode 100644 index 0000000..a7381dc --- /dev/null +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DailyDemandBuilder.groovy @@ -0,0 +1,87 @@ +package pl.com.bottega.factory.demand.forecasting + +import java.time.Clock +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +import static pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged.Change; + +class DailyDemandBuilder { + + Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()) + DailyDemand.Events events + + String refNo = "3009000" + private LocalDate date = LocalDate.now(clock) + private Demand base + private Adjustment adjustment + + DailyDemand build() { + new DailyDemand(new DailyId(refNo, date), events, base, adjustment) + } + + DailyDemandBuilder reset() { + nothingDemanded() + noAdjustments() + } + + Object asType(Class clazz) { + clazz == DailyDemand ? build() : super.asType(clazz) + } + + DailyDemandBuilder events(DailyDemand.Events events) { + this.events = events + this + } + + DailyDemandBuilder nextDate() { + date.plusDays(1) + this + } + + DailyDemandBuilder date(LocalDate date) { + this.date = date + this + } + + DailyDemandBuilder nothingDemanded() { + base = null + this + } + + DailyDemandBuilder noAdjustments() { + adjustment = null + this + } + + DailyDemandBuilder demandedLevels(long level) { + base = Demand.of(level) + this + } + + DailyDemandBuilder adjustedTo(long level) { + adjustment = new Adjustment(Demand.of(level), false) + this + } + + DailyDemandBuilder stronglyAdjustedTo(long level) { + adjustment = new Adjustment(Demand.of(level), true) + this + } + + Demand newCallOffDemand(long level) { + Demand.of(level) + } + + Adjustment adjustDemandTo(long level) { + new Adjustment(Demand.of(level), false) + } + + DailyDemand.LevelChanged levelChanged(long previous, long current) { + new DailyDemand.LevelChanged( + new DailyId(refNo, date), + new Change(Demand.of(previous), Demand.of(current)) + ) + } +} 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 new file mode 100644 index 0000000..288f428 --- /dev/null +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandAdjustmentSpec.groovy @@ -0,0 +1,101 @@ +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 + +class DemandAdjustmentSpec extends Specification { + + def events = Mock(DemandEvents) + def builder = new ProductDemandBuilder(events: events) + + def "Adjusted demands should be stored"() { + given: + def today = LocalDate.now(builder.clock) + def demand = demand(2800, 0) + def adjustments = adjustments([(today): 1000]) + + when: + demand.adjust(adjustments) + + then: + 1 * events.emit(levelChanged([2800, 1000])) + } + + def "Adjustment of future demands is possible"() { + given: + def today = LocalDate.now(builder.clock) + def demand = demand(2800) + def adjustments = adjustments([(today.plusDays(1)): 1000]) + + when: + demand.adjust(adjustments) + + then: + 1 * events.emit(levelChanged(notChanged(), [0, 1000])) + } + + def "Adjustment without changes should not generate event"() { + given: + def today = LocalDate.now(builder.clock) + def demand = demand(2800, 1000) + def adjustments = adjustments([(today): 2800, (today.plusDays(1)): 1000]) + + when: + demand.adjust(adjustments) + + then: + 0 * events.emit(_ as DemandedLevelsChanged) + } + + def "Should skip past demands adjustments"() { + given: + def pastDate = LocalDate.now(builder.clock).minusDays(2) + def demand = demand(2800, 0) + def adjustments = adjustments([(pastDate): 1000]) + + when: + demand.adjust(adjustments) + + then: + 0 * events.emit(_ as DemandedLevelsChanged) + } + + def "Adjustment should be idempotent"() { + given: + def today = LocalDate.now(builder.clock) + def demand = demand(2800, 0) + def adjustments = adjustments((today): 2000, (today.plusDays(1)): 3500) + + when: + demand.adjust(adjustments) + + then: + 1 * events.emit(levelChanged([2800, 2000], [0, 3500])) + + when: + builder.demands.clearUnitOfWork() + demand.adjust(adjustments) + + 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/DemandsRepositoryFake.groovy new file mode 100644 index 0000000..1900e10 --- /dev/null +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DemandsRepositoryFake.groovy @@ -0,0 +1,36 @@ +package pl.com.bottega.factory.demand.forecasting + +import java.time.Clock +import java.time.LocalDate + +class DemandsRepositoryFake extends Demands { + + DailyDemandBuilder builder + + DemandsRepositoryFake(String refNo, Clock clock) { + this.builder = new DailyDemandBuilder(refNo: refNo, clock: clock, events: this) + fetch = { date -> nothingDemanded(date) } + } + + DailyDemand nothingDemanded(LocalDate date) { + def demand = builder.reset() + .date(date) + .build() + fetched.put(date, demand) + demand + } + + DailyDemand demanded(LocalDate date, long level) { + def demand = builder.date(date) + .demandedLevels(level) + .noAdjustments() + .build() + fetched.put(date, demand) + demand + } + + void clearUnitOfWork() { + super.@changes.clear() + super.@warnings.clear() + } +} 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 new file mode 100644 index 0000000..afc42a9 --- /dev/null +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/DocumentProcessingSpec.groovy @@ -0,0 +1,101 @@ +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 + +class DocumentProcessingSpec extends Specification { + + def events = Mock(DemandEvents) + def builder = new ProductDemandBuilder(events: events) + + def "Updated demands should be stored"() { + given: + def today = LocalDate.now(builder.clock) + def demand = demand(2800, 0) + def document = document(today, 2000, 3500) + + when: + demand.process(document) + + then: + 1 * events.emit(levelChanged([2800, 2000], [0, 3500])) + } + + def "Demands for dates not present in system should be stored "() { + given: + def today = LocalDate.now(builder.clock) + def demand = demand(1000) + def document = document(today, 1000, 3500, 1000) + + when: + demand.process(document) + + then: + 1 * events.emit(levelChanged(notChanged(), [0, 3500], [0, 1000])) + } + + def "Document without changes should not generate event"() { + given: + def today = LocalDate.now(builder.clock) + def demand = demand(2800, 0) + def document = document(today, 2800, 0) + + when: + demand.process(document) + + then: + 0 * events.emit(_ as DemandedLevelsChanged) + } + + def "Should skip past demands from document"() { + given: + def pastDate = LocalDate.now(builder.clock).minusDays(2) + def demand = demand(0, 0) + def document = document(pastDate, 2800, 2800, 3500, 1000) + + when: + demand.process(document) + + then: + 1 * events.emit(levelChanged([0, 3500], [0, 1000])) + } + + def "Document processing should be idempotent"() { + given: + def today = LocalDate.now(builder.clock) + def demand = demand(2800, 0) + def document = document(today, 2000, 3500) + + when: + demand.process(document) + + then: + 1 * events.emit(levelChanged([2800, 2000], [0, 3500])) + + when: + builder.demands.clearUnitOfWork() + demand.process(document) + + 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 new file mode 100644 index 0000000..7d52085 --- /dev/null +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/KeepingDailyDemandsSpec.groovy @@ -0,0 +1,98 @@ +package pl.com.bottega.factory.demand.forecasting + +import spock.lang.PendingFeature +import spock.lang.Specification + +class KeepingDailyDemandsSpec extends Specification { + + def builder = new DailyDemandBuilder() + def events = Mock(DailyDemand.Events) + + def "Adjusted demands should be stored"() { + given: + def demand = demand() + .demandedLevels(2800) + .noAdjustments().build() + + when: + demand.adjust(adjustDemandTo(3500)) + + then: + demand.getLevel() == Demand.of(3500) + 1 * events.emit(levelChanged(2800, 3500)) + } + + def "Adjusted demands should be stored when there is no demand for product"() { + given: + def demand = demand() + .nothingDemanded() + .noAdjustments().build() + + when: + demand.adjust(adjustDemandTo(3500)) + + then: + demand.getLevel() == Demand.of(3500) + 1 * events.emit(levelChanged(0, 3500)) + } + + def "In standard case documented demands overrides adjustments"() { + given: + def demand = demand() + .demandedLevels(2800) + .adjustedTo(3500).build() + + when: + demand.update(newCallOffDemand(4000)) + + then: + demand.getLevel() == Demand.of(4000) + 1 * events.emit(levelChanged(3500, 4000)) + } + + def "Strong adjustment is kept even after processing of document"() { + given: + def demand = demand() + .demandedLevels(2800) + .stronglyAdjustedTo(3500).build() + + when: + demand.update(newCallOffDemand(2800)) + + then: + demand.getLevel() == Demand.of(3500) + 0 * events.emit(_ as DailyDemand.LevelChanged) + } + + @PendingFeature + def "Document update hidden by strong adjustment should rise warning"() { + given: + def demand = demand() + .demandedLevels(2800) + .stronglyAdjustedTo(3500).build() + + when: + demand.update(newCallOffDemand(5000)) + + then: + demand.getLevel() == Demand.of(3500) + 1 * events.emit(_ as DailyDemand.ReviewRequest) + } + + DailyDemandBuilder demand() { + builder.events = events + builder + } + + Demand newCallOffDemand(long level) { + builder.newCallOffDemand(level) + } + + Adjustment adjustDemandTo(long level) { + builder.adjustDemandTo(level) + } + + def levelChanged(long previous, long current) { + builder.levelChanged(previous, current) + } +} 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 new file mode 100644 index 0000000..06ead94 --- /dev/null +++ b/demand-forecasting-model/src/test/groovy/pl/com/bottega/factory/demand/forecasting/ProductDemandBuilder.groovy @@ -0,0 +1,57 @@ +package pl.com.bottega.factory.demand.forecasting + +import pl.com.bottega.factory.product.management.RefNoId + +import java.time.* + +import static DemandedLevelsChanged.Change +import static pl.com.bottega.factory.demand.forecasting.DemandEvents.DemandedLevelsChanged + +class ProductDemandBuilder { + + def refNo = "3009000" + def demands = new DemandsRepositoryFake(refNo, clock) + def clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()) + DemandEvents events + + ProductDemand demand(long ... levels) { + def date = LocalDate.now(clock) + for (long level : levels) { + demands.demanded(date, level) + date = date.plusDays(1) + } + new ProductDemand(new RefNoId(refNo), demands, clock, events) + } + + Document 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) { + results.put(date, Demand.of(level)) + date = date.plusDays(1) + } + new Document(created, refNo, results) + } + + AdjustDemand adjustDemand(Map adjustments) { + Map results = new HashMap<>() + adjustments.forEach { date, level -> + results.put(date, Adjustment.week(Demand.of(level))) + } + new AdjustDemand(refNo, results) + } + + DemandedLevelsChanged levelChanged(List... changes) { + def date = LocalDate.now(clock) + Map results = new HashMap<>() + for (def change : changes) { + if (change.size() == 2) { + results.put(new DailyId(refNo, date), new Change( + Demand.of(change[0]), + Demand.of(change[1]))) + } + date = date.plusDays(1) + } + new DemandedLevelsChanged(new RefNoId(refNo), results) + } +} diff --git a/demand-forecasting-model/src/test/java/pl/com/bottega/factory/demand/forecasting/DemandsFake.java b/demand-forecasting-model/src/test/java/pl/com/bottega/factory/demand/forecasting/DemandsFake.java new file mode 100644 index 0000000..0013b2a --- /dev/null +++ b/demand-forecasting-model/src/test/java/pl/com/bottega/factory/demand/forecasting/DemandsFake.java @@ -0,0 +1,20 @@ +package pl.com.bottega.factory.demand.forecasting; + +import java.time.LocalDate; +import java.util.Map; +import java.util.function.Function; + +public class DemandsFake extends Demands { + + public DemandsFake(Map fetched, + Function factory) { + super(); + this.fetched.putAll(fetched); + this.fetch = factory; + } + + public void clearUnitOfWork() { + this.changes.clear(); + this.warnings.clear(); + } +} diff --git a/demand-forecasting-model/src/test/resources/scenarios/DemandAdjustements.feature b/demand-forecasting-model/src/test/resources/scenarios/DemandAdjustements.feature new file mode 100644 index 0000000..98ace7b --- /dev/null +++ b/demand-forecasting-model/src/test/resources/scenarios/DemandAdjustements.feature @@ -0,0 +1,96 @@ +Feature: manual adjustments of demand + + sub domain: demand forecasting + keeps track of current and future customer needs for our products + + Domain story: + Adjust demand at day to amount, delivered. + >> demand.adjust(productRefNo, atDay, amount) + We can change only Demands for today and future. + + New demand is stored for further reference + Data from call-off document should be preserved (DON’T OVERRIDE THEM). + Adjust demand should be possible even + if there was no call-off document for that product. + In standard case future call-off documents should be stronger (overrides) adjustment, + but if customer warn us about opposite case import of call-off document should not remove previous adjustments. + + emit domain event demand changed + + Logistician note should be kept along with adjustment. + + outside of context boundary: + If new demand is not fulfilled by + current product stock and production forecast + there is a shortage in particular days and we need to rise an alert. + planner should be notified, + + if there are locked parts on stock, + QA task for recovering them should have high priority. + + Scenario: demand increased but fulfilled by current stock level + Given demand for product refNo "3009000": + | 0 | 100 | 300 | 0 | 0 | 0 | 0 | + When demand of refNo "3009000" is adjusted for "tomorrow" to 400 + Then demand is changed to 400 + Then current demands are + | 0 | 400 | 300 | 0 | 0 | 0 | 0 | + Then no shortage was found + Given current stock of proper parts of refNo "3009000" is 750 + + Scenario: demand increased, shortage is found + Given demand for product refNo "3009000": + | 0 | 100 | 300 | 0 | 0 | 0 | 0 | + Given no production of refNo "3009000" is planned + Given current stock of proper parts of refNo "3009000" is 350 + When demand of refNo "3009000" is adjusted for "tomorrow" to 400 + Then demand is changed + Then shortage of 250 parts for "day after tomorrow" is found + Then current demands are + | 0 | 400 | 300 | 0 | 0 | 0 | 0 | + + Scenario: demand increased, but stock forecast including production will be enough + Given demand for product refNo "3009000": + | 0 | 100 | 300 | 0 | 0 | 0 | 0 | + Given planned production of refNo "3009000": + | 300 | 0 | 0 | 0 | 0 | 0 | 0 | + Given current stock of proper parts of refNo "3009000" is 450 + When demand of refNo "3009000" is adjusted for "tomorrow" to 400 + Then demand is changed + Then no shortage was found + Then current demands are + | 0 | 400 | 300 | 0 | 0 | 0 | 0 | + + Scenario: next call-off will be stronger than adjustment + Given demand for product refNo "3009000": + | 0 | 100 | 0 | 0 | 0 | 0 | 0 | + When demand of refNo "3009000" is adjusted for "tomorrow" to 400 + Then demand is changed + When new call-off document for product refNo "3009000" contains: + | 0 | 100 | 0 | 0 | 0 | 0 | 0 | + Then demand is not changed + Then current demands are + | 0 | 100 | 0 | 0 | 0 | 0 | 0 | + + Scenario: in some cases adjustment will be stronger that future call-off + Given demand for product refNo "3009000": + | 0 | 100 | 0 | 0 | 0 | 0 | 0 | + When demand of refNo "3009000" is strongly adjusted for "tomorrow" to 400 + Then demand is changed + When new call-off document for product refNo "3009000" contains: + | 0 | 100 | 0 | 0 | 0 | 0 | 0 | + Then demand is changed + Then current demands are + | 0 | 400 | 0 | 0 | 0 | 0 | 0 | + + Scenario: strong adjustment will be reported if new call-off contains other value then previous + Given demand for product refNo "3009000": + | 0 | 100 | 0 | 0 | 0 | 0 | 0 | + When demand of refNo "3009000" is strongly adjusted for "tomorrow" to 400 + Then demand is changed + When new call-off document for product refNo "3009000" contains: + | 0 | 600 | 0 | 0 | 0 | 0 | 0 | + Then review request is shown on report [demand for refNo "3009000" was 100, adjusted to 400, but call-off has 600] + Then demand is not changed + Then current demands are + | 0 | 400 | 0 | 0 | 0 | 0 | 0 | diff --git a/demand-forecasting-model/src/test/resources/scenarios/ProductionPlanning.feature b/demand-forecasting-model/src/test/resources/scenarios/ProductionPlanning.feature new file mode 100644 index 0000000..2f5622a --- /dev/null +++ b/demand-forecasting-model/src/test/resources/scenarios/ProductionPlanning.feature @@ -0,0 +1,90 @@ +Feature: Production planning + + // https://github.com/michal-michaluk/production/tree/master + + sub domain: production planning + Keep track of correct production plan + to ensure sufficient capacity + and high utilization of resources in tune with forecast demand + + Domain story: + Production plan schedule new production on line, using form, at time for duration. + >> productionPlan.scheduleNewProduction(line, form, time, duration) + Only if form will be available at the time for whole duration, production may by planned. + Production includes retooling time, parts output of used form and utilization of human resources + on that production stage. + If production on given line overlaps with other: preceding must shrink, + succeeding one must start later. + If consecutive productions use same from, retooling time between is zero. [not mentioned prev.] + Production forecasts (parts output * production duration) changes new and changed productions. + Overall utilization of human resources increases. + Shortage may arise if insufficient production was planned. + + + Production plan adjust production to time and duration. + >> productionPlan.adjust(production, time, duration) + If production on given line overlaps with other: preceding must shrink, + succeeding one must start later. + If consecutive productions use same from, retooling time between is zero. + Production forecasts (parts output * production duration) changes for changed productions. + Overall utilization of human resources may change. + Shortage may arise if insufficient production was planned. + + Scenario: schedule new production consecutive production must shrink + Given plan for next monday: + | line | beginAt | duration | productRefNo | + | 1 | 6:00 | 3.5h | 3009000 | + When new production is scheduled for next monday: + | line | beginAt | duration | productRefNo | + | 1 | 8:00 | 2.0h | 4400800 | + Then demand is changed + | line | beginAt | duration | productRefNo | + | 1 | 6:00 | 2.0h | 3009000 | + | 1 | 8:00 | 2.0h | 4400800 | + Then current production outputs for "3009000" are + | 2000 | 0 | 0 | 0 | 0 | 0 | 0 | + Then current production outputs for "4400800" are + | 3500 | 0 | 0 | 0 | 0 | 0 | 0 | + Then no shortage was found + + Scenario: schedule new production succeeding productions must start later + Given plan for next monday: + | line | beginAt | duration | productRefNo | + | 1 | 6:00 | 2.0h | 3009000 | + | 1 | 9:30 | 3.5h | 3009000A | + When new production is scheduled for next monday: + | line | beginAt | duration | productRefNo | + | 1 | 8:00 | 2.0h | 4400800 | + Then demand is changed + | line | beginAt | duration | productRefNo | + | 1 | 6:00 | 2.0h | 3009000 | + | 1 | 8:00 | 2.0h | 4400800 | + | 1 | 10:00 | 3.5h | 3009000A | + Then current production outputs for "3009000" are + | 2000 | 0 | 0 | 0 | 0 | 0 | 0 | + Then current production outputs for "3009000A" are + | 2000 | 0 | 0 | 0 | 0 | 0 | 0 | + Then current production outputs for "4400800" are + | 3500 | 0 | 0 | 0 | 0 | 0 | 0 | + Then no shortage was found + + Scenario: schedule new production consecutive production must shrink and succeeding must start later + Given plan for next monday: + | line | beginAt | duration | productRefNo | + | 1 | 6:00 | 3.5h | 3009000 | + | 1 | 9:30 | 3.5h | 3009000A | + When new production is scheduled for next monday: + | line | beginAt | duration | productRefNo | + | 1 | 8:00 | 2.0h | 4400800 | + Then demand is changed + | line | beginAt | duration | productRefNo | + | 1 | 6:00 | 2.0h | 3009000 | + | 1 | 8:00 | 2.0h | 4400800 | + | 1 | 10:00 | 3.5h | 3009000A | + Then current production outputs for "3009000" are + | 2000 | 0 | 0 | 0 | 0 | 0 | 0 | + Then current production outputs for "3009000A" are + | 2000 | 0 | 0 | 0 | 0 | 0 | 0 | + Then current production outputs for "4400800" are + | 3500 | 0 | 0 | 0 | 0 | 0 | 0 | + Then no shortage was found diff --git a/demand-forecasting-model/src/test/resources/scenarios/domain-stories.txt b/demand-forecasting-model/src/test/resources/scenarios/domain-stories.txt new file mode 100644 index 0000000..a78bef4 --- /dev/null +++ b/demand-forecasting-model/src/test/resources/scenarios/domain-stories.txt @@ -0,0 +1,81 @@ + +Production plan schedule new production on line, using form, at time for duration.
 + >> productionPlan.scheduleNewProduction(line, form, time, duration)
 + Only if form will be available at the time for whole duration, production may by planned.
 + Production includes retooling time, parts output of used form and utilization of human resources
 + on that production stage.
 + If production on given line overlaps with other: preceding must shrink, + succeeding one must start later.
 + If consecutive productions use same from, retooling time between is zero. [not mentioned prev.] + 
 Production forecasts (parts output * production duration) changes new and changed productions. + 
 Overall utilization of human resources increases.
 + Shortage may arise if insufficient production was planned. + + +Production plan adjust production to time and duration. + 
>> productionPlan.adjust(production, time, duration)
 + If production on given line overlaps with other: preceding must shrink,
 + succeeding one must start later.
 + If consecutive productions use same from, retooling time between is zero.
 + Production forecasts (parts output * production duration) changes for changed productions.
 + Overall utilization of human resources may change.
 + Shortage may arise if insufficient production was planned. + + +Daily processing of callof document: + >> callofProcess.process(document)
 + for all products included in callof document
 + New demand are stored for further reference
 + If new demand is not fulfilled by product stock and production forecast + there is a shortage in particular days and we need to rise an alert.
 + planner should be notified in that case, + if there are locked parts on stock, + QA task for recovering them should have high priority. + + +Adjust demand at day to amount, delivered. + >> demand.require(productRefNo, atDay, amount)
 + New demand is stored for further reference
 + If new demand is not fulfilled by
 + current product stock and production forecast
 + there is a shortage in particular days and we need to rise an alert.
 + planner should be notified,
 if there are locked parts on stock,
 + QA task for recovering them should have high priority. + + 

ADDED: 
We can change only Demands for today and future.
 + Data from callof document should be preserved in database (DON’T OVERRIDE THEM). + 
Should be possible to adjust demand even + if there was no callof document for that product. + 

Logistician note should be kept along with adjustment. + + +Lock all parts from storage unit on stock. + 
>> stock.lock(storageUnit)
 + parts from storage unit are locked on stock + If locking parts can lead to insufficient stock for next deliveries, + parts recovery should have high priority.
 + If there is a potential shortage in particular days, + we need to rise an soft notification to planner. + + +Unlock storage unit, recover X parts, Y parts was scrapped. + 
>> stock.unlock(storageUnit, recovered, scrapped)
 + Recovered parts are back on stock.
 + Scrapped parts are removed from stock.
 + If demand is not fulfilled by current product stock and production forecast + there is a shortage in particular days and we need to rise an alert. + + +Register newly produced parts on stock.
 + >> stock.registerNew(storageUnit)
 + new parts are available on stock.
 + If output from production is smaller than planned
 + it may lead to shortage in next days. + + +Remove delivered parts from stock. + 
>> stock.delivered(deliveryNote) + 
 If parts delivered during day exceed registered customer demand,
 + demand for next day will be probably corrected with upcoming callof document, + but in rare cases it may be caused by not registered additional delivery
 + (lack of manual adjustments of demand in system). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3e93076 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: "2" + +volumes: + data: + driver: local + +services: + db: + build: ./database/ + volumes: + - data:/var/lib/postgresql/data + ports: + - "5432:5432" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8c84277 --- /dev/null +++ b/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + pl.com.bottega + factory + pom + 1.0-SNAPSHOT + + + + shared-kernel-model + demand-forecasting-model + shortages-prediction-model + + + + diff --git a/shared-kernel-model/pom.xml b/shared-kernel-model/pom.xml new file mode 100644 index 0000000..6587632 --- /dev/null +++ b/shared-kernel-model/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + pl.com.bottega + shared-kernel-model + jar + 1.0-SNAPSHOT + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.7.0 + + 1.8 + 1.8 + -parameters + + + + org.codehaus.gmavenplus + gmavenplus-plugin + 1.6 + + + + compileTests + + + + + + maven-surefire-plugin + 2.20.1 + + false + + **/*Spec.java + **/*Test.java + + + + + + + + + org.projectlombok + lombok + 1.16.18 + compile + + + org.assertj + assertj-core + 3.8.0 + test + + + info.cukes + cucumber-java + 1.2.5 + test + + + info.cukes + cucumber-core + 1.2.5 + test + + + org.spockframework + spock-core + 1.1-groovy-2.4 + test + + + \ No newline at end of file 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 new file mode 100644 index 0000000..f7b1582 --- /dev/null +++ b/shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DailyId.java @@ -0,0 +1,15 @@ +package pl.com.bottega.factory.demand.forecasting; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@AllArgsConstructor +@EqualsAndHashCode +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/Demand.java b/shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Demand.java new file mode 100644 index 0000000..1e599bf --- /dev/null +++ b/shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/Demand.java @@ -0,0 +1,25 @@ +package pl.com.bottega.factory.demand.forecasting; + +import lombok.Value; + +@Value +public class Demand { + long level; + Schema schema; + + public enum Schema { + AtDayStart, Every3hours, TillDayEnd + } + + public static Demand nothingDemanded() { + return of(0); + } + + public static Demand of(long level) { + return new Demand(level, Schema.TillDayEnd); + } + + public static Demand of(long level, Schema schema) { + return new Demand(level, schema); + } +} 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 new file mode 100644 index 0000000..9ee786f --- /dev/null +++ b/shared-kernel-model/src/main/java/pl/com/bottega/factory/demand/forecasting/DemandEvents.java @@ -0,0 +1,28 @@ +package pl.com.bottega.factory.demand.forecasting; + +import lombok.Value; +import pl.com.bottega.factory.product.management.RefNoId; + +import java.util.Collections; +import java.util.Map; + +public interface DemandEvents { + void emit(DemandedLevelsChanged event); + + @Value + class DemandedLevelsChanged { + RefNoId id; + Map results; + + public DemandedLevelsChanged(RefNoId id, Map results) { + this.id = id; + this.results = Collections.unmodifiableMap(results); + } + + @Value + public static class Change { + Demand previous; + Demand current; + } + } +} 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 new file mode 100644 index 0000000..ac3fdd8 --- /dev/null +++ b/shared-kernel-model/src/main/java/pl/com/bottega/factory/product/management/RefNoId.java @@ -0,0 +1,12 @@ +package pl.com.bottega.factory.product.management; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@EqualsAndHashCode +public class RefNoId { + private final String refNo; +} diff --git a/shortages-prediction-model/pom.xml b/shortages-prediction-model/pom.xml new file mode 100644 index 0000000..baf813e --- /dev/null +++ b/shortages-prediction-model/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + pl.com.bottega + shortages-prediction-model + jar + 1.0-SNAPSHOT + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.7.0 + + 1.8 + 1.8 + -parameters + + + + org.codehaus.gmavenplus + gmavenplus-plugin + 1.6 + + + + compileTests + + + + + + maven-surefire-plugin + 2.20.1 + + false + + **/*Spec.java + **/*Test.java + + + + + + + + + pl.com.bottega + shared-kernel-model + 1.0-SNAPSHOT + + + org.projectlombok + lombok + 1.16.18 + compile + + + org.assertj + assertj-core + 3.8.0 + test + + + info.cukes + cucumber-java + 1.2.5 + test + + + info.cukes + cucumber-core + 1.2.5 + test + + + org.spockframework + spock-core + 1.1-groovy-2.4 + test + + + \ No newline at end of file diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/Configuration.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/Configuration.java new file mode 100644 index 0000000..d23090e --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/Configuration.java @@ -0,0 +1,8 @@ +package pl.com.bottega.factory.shortages.prediction; + +/** + * Created by michal on 02.02.2017. + */ +public interface Configuration { + int shortagePredictionDaysAhead(); +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/Shortages.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/Shortages.java new file mode 100644 index 0000000..573220b --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/Shortages.java @@ -0,0 +1,67 @@ +package pl.com.bottega.factory.shortages.prediction; + +import lombok.AllArgsConstructor; +import lombok.Value; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Optional; +import java.util.SortedMap; +import java.util.TreeMap; + +/** + * Levels missing to satisfy customer demand of particular product. + *

+ * Created by michal on 22.10.2015. + */ +@Value +public class Shortages { + + private final String refNo; + private final long lockedParts; + private final LocalDateTime found; + private final SortedMap shortages; + + public static Shortages.Builder builder(String refNo, long locked, LocalDateTime found) { + return new Builder(refNo, locked, found); + } + + public static boolean areNotSame(Shortages first, Shortages second) { + return !areSame(first, second); + } + + public static boolean areSame(Shortages first, Shortages second) { + boolean noShortages = first == null && second == null; + boolean onlyOne = first == null && second != null || first != null && second == null; + if (noShortages || onlyOne) return false; + boolean sameProduct = first.refNo.equals(second.refNo); + boolean sameNumbers = first.shortages.equals(second.shortages); + return sameProduct && sameNumbers; + } + + public boolean anyBefore(LocalDateTime time) { + return shortages.firstKey().isBefore(time); + } + + @AllArgsConstructor + public static class Builder { + private final String refNo; + private final long locked; + private final LocalDateTime found; + private final SortedMap gaps = new TreeMap<>(); + + public Builder add(LocalDateTime time, long level) { + gaps.put(time, Math.abs(level)); + return this; + } + + public Optional build() { + if (gaps.isEmpty()) { + return Optional.empty(); + } else { + return Optional.of(new Shortages(refNo, locked, found, + Collections.unmodifiableSortedMap(gaps))); + } + } + } +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/CurrentStock.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/CurrentStock.java new file mode 100644 index 0000000..fa1efcc --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/CurrentStock.java @@ -0,0 +1,9 @@ +package pl.com.bottega.factory.shortages.prediction.calculation; + +import lombok.Value; + +@Value +public class CurrentStock { + long level; + long locked; +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/Demands.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/Demands.java new file mode 100644 index 0000000..14ea8a7 --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/Demands.java @@ -0,0 +1,16 @@ +package pl.com.bottega.factory.shortages.prediction.calculation; + +import lombok.AllArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +@AllArgsConstructor +class Demands { + + private final Map demands; + + long get(LocalDateTime time) { + return demands.getOrDefault(time, 0L); + } +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/Forecast.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/Forecast.java new file mode 100644 index 0000000..7531b7a --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/Forecast.java @@ -0,0 +1,42 @@ +package pl.com.bottega.factory.shortages.prediction.calculation; + +import lombok.AllArgsConstructor; +import pl.com.bottega.factory.shortages.prediction.Shortages; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@AllArgsConstructor +public class Forecast { + + String refNo; + LocalDateTime created; + List times; + CurrentStock stock; + ProductionOutputs outputs; + Demands demands; + + public Optional findShortages() { + // TODO ASK including locked or only proper parts + // TODO ASK current stock or on day start? what if we are in the middle of production a day? + long level = stock.getLevel(); + + Shortages.Builder found = Shortages.builder(refNo, stock.getLocked(), created); + LocalDateTime lastTime = created; + for (LocalDateTime time : times) { + long demand = demands.get(time); + long produced = outputs.getOutput(lastTime, time); + + long levelOnDelivery = level + produced - demand; + + if (levelOnDelivery < 0) { + found.add(time, levelOnDelivery); + } + // TODO: ASK accumulated shortages or reset when under zero? + level = levelOnDelivery >= 0 ? levelOnDelivery : 0; + lastTime = time; + } + return found.build(); + } +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/Forecasts.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/Forecasts.java new file mode 100644 index 0000000..7dfae88 --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/Forecasts.java @@ -0,0 +1,5 @@ +package pl.com.bottega.factory.shortages.prediction.calculation; + +public interface Forecasts { + Forecast get(String refNo, int daysAhead); +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/ProductionForecast.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/ProductionForecast.java new file mode 100644 index 0000000..4a2d2bc --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/ProductionForecast.java @@ -0,0 +1,55 @@ +package pl.com.bottega.factory.shortages.prediction.calculation; + +import lombok.AllArgsConstructor; +import lombok.Value; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Value +class ProductionForecast { + List items; + + ProductionOutputs outputsInTimes(LocalDateTime now, Set times) { + return new ProductionOutputs( + (times.contains(now) ? times.stream() : Stream.concat(Stream.of(now), times.stream())) + .parallel() + .collect(Collectors.toMap( + Function.identity(), + time -> items.parallelStream() + .mapToLong(item -> item.partsAt(time)) + .sum() + )) + ); + } + + @AllArgsConstructor + static class Item { + final LocalDateTime start; + final Duration duration; + final int partsPerMinute; + + long partsAt(LocalDateTime time) { + if (startsAfter(time)) { + return 0; + } + if (endsBefore(time)) { + return duration.toMinutes() * partsPerMinute; + } + return Duration.between(start, time).getSeconds() * partsPerMinute / 60; + } + + boolean startsAfter(LocalDateTime time) { + return start.isAfter(time); + } + + boolean endsBefore(LocalDateTime time) { + return start.plus(duration).isBefore(time); + } + } +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/ProductionOutputs.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/ProductionOutputs.java new file mode 100644 index 0000000..e6aef9e --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/calculation/ProductionOutputs.java @@ -0,0 +1,19 @@ +package pl.com.bottega.factory.shortages.prediction.calculation; + +import lombok.AllArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +@AllArgsConstructor +class ProductionOutputs { + + private final Map outputs; + + long getOutput(LocalDateTime from, LocalDateTime to) { + if (!outputs.containsKey(from) || !outputs.containsKey(to)) { + throw new IllegalArgumentException("No pre-calculated output for time " + to); + } + return outputs.get(to) - outputs.get(from); + } +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/NewShortage.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/NewShortage.java new file mode 100644 index 0000000..da4d9a0 --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/NewShortage.java @@ -0,0 +1,16 @@ +package pl.com.bottega.factory.shortages.prediction.monitoring; + +import lombok.Value; +import pl.com.bottega.factory.shortages.prediction.Shortages; + +/** + * Created by michal on 03.02.2017. + */ +@Value +public class NewShortage { + + public enum After {DemandChanged, PlanChanged, StockChanged, LockedParts} + + After trigger; + Shortages shortages; +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageDiffPolicy.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageDiffPolicy.java new file mode 100644 index 0000000..9a1ca9c --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageDiffPolicy.java @@ -0,0 +1,10 @@ +package pl.com.bottega.factory.shortages.prediction.monitoring; + +import pl.com.bottega.factory.shortages.prediction.Shortages; + +interface ShortageDiffPolicy { + + ShortageDiffPolicy ValuesAreEquals = Shortages::areNotSame; + + boolean areDifferent(Shortages previous, Shortages found); +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageEvents.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageEvents.java new file mode 100644 index 0000000..e57dd55 --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageEvents.java @@ -0,0 +1,7 @@ +package pl.com.bottega.factory.shortages.prediction.monitoring; + +public interface ShortageEvents { + void emit(NewShortage event); + + void emit(ShortageSolved event); +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortagePredictionProcess.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortagePredictionProcess.java new file mode 100644 index 0000000..c44dff7 --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortagePredictionProcess.java @@ -0,0 +1,57 @@ +package pl.com.bottega.factory.shortages.prediction.monitoring; + +import lombok.AllArgsConstructor; +import pl.com.bottega.factory.shortages.prediction.Configuration; +import pl.com.bottega.factory.shortages.prediction.Shortages; +import pl.com.bottega.factory.shortages.prediction.calculation.Forecast; +import pl.com.bottega.factory.shortages.prediction.calculation.Forecasts; +import pl.com.bottega.factory.shortages.prediction.monitoring.NewShortage.After; + +import java.util.Optional; + +/** + * Created by michal on 02.02.2017. + */ +@AllArgsConstructor +public class ShortagePredictionProcess { + + private final String refNo; + private Shortages known; + + private final ShortageDiffPolicy diffPolicy; + private final Forecasts forecasts; + private final Configuration configuration; + private final ShortageEvents events; + + public void onDemandChanged() { + predict(After.DemandChanged); + } + + public void onPlanChanged() { + predict(After.PlanChanged); + } + + public void onStockChanged() { + predict(After.StockChanged); + } + + public void onLockedParts() { + predict(After.LockedParts); + } + + private void predict(After event) { + Forecast forecast = forecasts.get(refNo, + configuration.shortagePredictionDaysAhead()); + + Optional newlyFound = forecast.findShortages(); + + boolean areDifferent = diffPolicy.areDifferent(this.known, newlyFound.orElse(null)); + if (areDifferent && newlyFound.isPresent()) { + this.known = newlyFound.get(); + events.emit(new NewShortage(event, newlyFound.get())); + } else if (known != null && !newlyFound.isPresent()) { + this.known = null; + events.emit(new ShortageSolved(refNo)); + } + } +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortagePredictionProcessRepository.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortagePredictionProcessRepository.java new file mode 100644 index 0000000..3036065 --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortagePredictionProcessRepository.java @@ -0,0 +1,12 @@ +package pl.com.bottega.factory.shortages.prediction.monitoring; + +import pl.com.bottega.factory.product.management.RefNoId; + +/** + * Created by michal on 03.02.2017. + */ +public interface ShortagePredictionProcessRepository { + ShortagePredictionProcess get(RefNoId refNo); + + void save(ShortagePredictionProcess model); +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageSolved.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageSolved.java new file mode 100644 index 0000000..09ddca3 --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageSolved.java @@ -0,0 +1,8 @@ +package pl.com.bottega.factory.shortages.prediction.monitoring; + +import lombok.Value; + +@Value +public class ShortageSolved { + String refNo; +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/NotificationOfShortage.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/NotificationOfShortage.java new file mode 100644 index 0000000..4bcbeec --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/NotificationOfShortage.java @@ -0,0 +1,67 @@ +package pl.com.bottega.factory.shortages.prediction.notification; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Singular; +import lombok.Value; +import pl.com.bottega.factory.shortages.prediction.Shortages; +import pl.com.bottega.factory.shortages.prediction.monitoring.NewShortage; +import pl.com.bottega.factory.shortages.prediction.monitoring.NewShortage.After; +import pl.com.bottega.factory.shortages.prediction.monitoring.ShortageEvents; +import pl.com.bottega.factory.shortages.prediction.monitoring.ShortageSolved; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Map; + +@AllArgsConstructor +class NotificationOfShortage implements ShortageEvents { + + private final QualityTasks qualityTasks; + private final Clock clock; + + private final RecoveryTaskPriorityChangePolicy policy; + private final NotificationRules rules; + + static NotificationRules rulesOfPlannerNotification(Notifications notifications) { + return NotificationRules.builder() + .rule(After.DemandChanged, notifications::alertPlanner) + .rule(After.PlanChanged, notifications::markOnPlan) + .rule(After.StockChanged, notifications::alertPlanner) + .rule(After.LockedParts, notifications::softNotifyPlanner) + .otherwise(notifications::alertPlanner) + .build(); + } + + @Override + public void emit(NewShortage event) { + Shortages shortage = event.getShortages(); + rules.wayOfNotificationAfter(event.getTrigger()) + .notifyAbout(event.getShortages()); + + if (policy.shouldIncreasePriority(LocalDateTime.now(clock), shortage)) { + qualityTasks.increasePriorityFor(shortage.getRefNo()); + } + } + + @Override + public void emit(ShortageSolved event) { + + } + + @Value + @Builder + static class NotificationRules { + @Singular + Map rules; + Notificator otherwise; + + Notificator wayOfNotificationAfter(After trigger) { + return rules.getOrDefault(trigger, otherwise); + } + } + + interface Notificator { + void notifyAbout(Shortages shortages); + } +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/Notifications.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/Notifications.java new file mode 100644 index 0000000..e961b25 --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/Notifications.java @@ -0,0 +1,14 @@ +package pl.com.bottega.factory.shortages.prediction.notification; + +import pl.com.bottega.factory.shortages.prediction.Shortages; + +/** + * Created by michal on 02.02.2017. + */ +public interface Notifications { + void alertPlanner(Shortages shortage); + + void softNotifyPlanner(Shortages shortage); + + void markOnPlan(Shortages shortage); +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/QualityTasks.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/QualityTasks.java new file mode 100644 index 0000000..28c9885 --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/QualityTasks.java @@ -0,0 +1,8 @@ +package pl.com.bottega.factory.shortages.prediction.notification; + +/** + * Created by michal on 02.02.2017. + */ +public interface QualityTasks { + void increasePriorityFor(String productRefNo); +} diff --git a/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/RecoveryTaskPriorityChangePolicy.java b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/RecoveryTaskPriorityChangePolicy.java new file mode 100644 index 0000000..8503715 --- /dev/null +++ b/shortages-prediction-model/src/main/java/pl/com/bottega/factory/shortages/prediction/notification/RecoveryTaskPriorityChangePolicy.java @@ -0,0 +1,27 @@ +package pl.com.bottega.factory.shortages.prediction.notification; + +import pl.com.bottega.factory.shortages.prediction.Shortages; + +import java.time.LocalDateTime; + +/** + * Created by michal on 18.05.2017. + */ +public interface RecoveryTaskPriorityChangePolicy { + + static RecoveryTaskPriorityChangePolicy never() { + return (LocalDateTime now, Shortages shortage) -> false; + } + + static RecoveryTaskPriorityChangePolicy onlyIn1DaysAhead() { + return shortageInDays(1); + } + + static RecoveryTaskPriorityChangePolicy shortageInDays(long shortageInXDays) { + return (LocalDateTime now, Shortages shortage) -> + shortage.getLockedParts() > 0 && shortage.anyBefore( + now.plusDays(shortageInXDays)); + } + + boolean shouldIncreasePriority(LocalDateTime now, Shortages shortage); +} diff --git a/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationAlgorithmSpec.groovy b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationAlgorithmSpec.groovy new file mode 100644 index 0000000..8daf8f5 --- /dev/null +++ b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationAlgorithmSpec.groovy @@ -0,0 +1,171 @@ +package pl.com.bottega.factory.shortages.prediction.calculation + +import spock.lang.Specification + +class ShortagesCalculationAlgorithmSpec extends Specification + implements ShortagesCalculationAssemblerTrait { + + void setup() { + TimeGrammar.apply() + } + + def "Takes stock into account"() { + given: + def forecast = forecast( + stock(1000L), + demands((now + 20.min): 500L), + noProductions() + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == noShortages() + } + + def "Locked stock is not included"() { + given: + def forecast = forecast( + stock(0L, 1000L), + demands((now + 20.min): 500L), + noProductions() + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == shortage((now + 20.min): 500L, 1000L) + } + + def "Shortages are not cumulative"() { + given: + def forecast = forecast( + stock(0), + demands((now + 20.min): 500L, (now + 1.day): 500L), + noProductions() + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == shortage( + (now + 20.min): 500L, + (now + 1.day): 500L + ) + } + + def "Includes momentary delivery"() { + given: + def forecast = forecast( + stock(0), + demands((now): 500L), + noProductions() + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == shortage((now): 500L) + } + + def "No demands means no shortages"() { + given: + def forecast = forecast( + stock(0), + noDemands(), + noProductions() + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == noShortages() + } + + def "Takes production plan into account"() { + given: + def forecast = forecast( + stock(0), + demands((now + 5.h): 500L), + plan([production(now, 50.min, 10)]) + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == noShortages() + } + + def "Takes current production partially into account"() { + given: + def forecast = forecast( + stock(0), + demands((now + 20.min): 500L), + plan([production(now, 50.min, 10)]) + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == shortage((now + 20.min): 300L) + } + + def "Future productions cannot be counted into delivery"() { + given: + def forecast = forecast( + stock(0), + demands((now): 500L), + plan([production(now + 1.h, 50.min, 10)]) + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == shortage((now): 500L) + } + + def "Production outputs are cumulative"() { + given: + def forecast = forecast( + stock(0), + demands((now + 30.min): 600L), + plan([production(now, 50.min, 10), production(now, 50.min, 10)]) + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == noShortages() + } + + def "Production outputs are not cumulative between deliveries"() { + given: + def forecast = forecast( + stock(0), + demands( + (now + 60.min): 500L, // first delivery + (now + 1.day + 60.min): 500L + 100L), // second delivery + plan([ + production(now, 50.min, 10), // consumed by first delivery + production(now + 1.day, 50.min, 10) // provides 500 for second delivery + ]) + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == shortage( + (now + 1.day + 60.min): 100L + ) + } +} diff --git a/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationAssembler.groovy b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationAssembler.groovy new file mode 100644 index 0000000..083e054 --- /dev/null +++ b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationAssembler.groovy @@ -0,0 +1,4 @@ +package pl.com.bottega.factory.shortages.prediction.calculation + +class ShortagesCalculationAssembler implements ShortagesCalculationAssemblerTrait { +} diff --git a/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationAssemblerTrait.groovy b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationAssemblerTrait.groovy new file mode 100644 index 0000000..1a61fb9 --- /dev/null +++ b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationAssemblerTrait.groovy @@ -0,0 +1,67 @@ +package pl.com.bottega.factory.shortages.prediction.calculation + +import pl.com.bottega.factory.shortages.prediction.Shortages + +import java.time.Duration +import java.time.LocalDateTime + +trait ShortagesCalculationAssemblerTrait { + + LocalDateTime now = LocalDateTime.now() + String refNo = "3009000" + Set times + + Forecasts forecastProvider(CurrentStock stock, Demands demands, ProductionOutputs outputs) { + def forecast = forecast(stock, demands, outputs) + return { String refNo, int daysAhead -> forecast } as Forecasts + } + + Forecast forecast(CurrentStock stock, Demands demands, ProductionOutputs outputs) { + new Forecast(refNo, now, times as List, stock, outputs, demands) + } + + ProductionOutputs noProductions() { + new ProductionForecast([]) + .outputsInTimes(now, times) + } + + ProductionOutputs plan(List productions) { + new ProductionForecast(productions) + .outputsInTimes(now, times) + } + + ProductionForecast.Item production(LocalDateTime start, Duration duration, int partsPerMinute) { + new ProductionForecast.Item(start, duration, partsPerMinute) + } + + Demands noDemands() { + times = Collections.emptySet() + new Demands([:]) + } + + Demands demands(Map demands) { + times = demands.keySet() + new Demands(demands) + } + + CurrentStock stock(long levels) { + new CurrentStock(levels, 0) + } + + CurrentStock stock(long level, long locked) { + new CurrentStock(level, locked) + } + + Optional noShortages() { + Optional.empty() + } + + Optional shortage(Map missing, long locked = 0) { + def shortages = Shortages.builder(refNo, locked, now) + + missing.each { time, level -> shortages.add(time, level) } + + shortages.build() + } + +} diff --git a/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationExamplesSpec.groovy b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationExamplesSpec.groovy new file mode 100644 index 0000000..7fa786f --- /dev/null +++ b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/ShortagesCalculationExamplesSpec.groovy @@ -0,0 +1,115 @@ +package pl.com.bottega.factory.shortages.prediction.calculation + +import spock.lang.Specification + +class ShortagesCalculationExamplesSpec extends Specification + implements ShortagesCalculationAssemblerTrait { + + void setup() { + TimeGrammar.apply() + } + + def "Constant week delivery covered by current stock"() { + given: + def forecast = forecast( + stock(7 * 500L), + demands( + (now): 500L, + (now + 1.day): 500L, + (now + 2.day): 500L, + (now + 3.day): 500L, + (now + 4.day): 500L, + (now + 5.day): 500L, + (now + 6.day): 500L + ), + noProductions() + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == noShortages() + } + + def "Constant week delivery partially covered by current stock"() { + given: + def forecast = forecast( + stock(4 * 500L), + demands( + (now): 500L, + (now + 1.day): 500L, + (now + 2.day): 500L, + (now + 3.day): 500L, + (now + 4.day): 500L, + (now + 5.day): 500L, + (now + 6.day): 500L + ), + noProductions() + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == shortage( + (now + 4.day): 500L, + (now + 5.day): 500L, + (now + 6.day): 500L + ) + } + + def "Constant week delivery covered by stock and plan"() { + given: + def forecast = forecast( + stock(6 * 500L), + demands( + (now): 500L, + (now + 1.day): 500L, + (now + 2.day): 500L, + (now + 3.day): 500L, + (now + 4.day): 500L, + (now + 5.day): 500L, + (now + 6.day): 500L + ), + plan([production((now + 1.day), 50.min, 10)]) + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == noShortages() + } + + def "Constant delivery with regular productions"() { + given: + def forecast = forecast( + stock(500L), + demands( + (now): 500L, + (now + 1.day): 500L, + (now + 2.day): 500L, + (now + 3.day): 500L, + (now + 4.day): 500L, + (now + 5.day): 500L, + (now + 6.day): 500L + ), + plan([ + production((now), 50.min, 10), + production((now + 1.day), 50.min, 10), + production((now + 2.day), 50.min, 10), + production((now + 3.day), 50.min, 10) + ]) + ) + + when: + def shortages = forecast.findShortages() + + then: + shortages == shortage( + (now + 5.day): 500L, + (now + 6.day): 500L, + ) + } +} diff --git a/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/TimeGrammar.groovy b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/TimeGrammar.groovy new file mode 100644 index 0000000..4f330e4 --- /dev/null +++ b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/calculation/TimeGrammar.groovy @@ -0,0 +1,21 @@ +package pl.com.bottega.factory.shortages.prediction.calculation + +import java.time.Duration +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +class TimeGrammar { + static getDays(Integer self) { Duration.ofDays self } + + static getDay(Integer self) { Duration.ofDays self } + + static getH(Integer self) { Duration.ofHours self } + + static getMin(Integer self) { Duration.ofMinutes self } + + static String toString(LocalDateTime self, String pattern) { self.format(DateTimeFormatter.ofPattern(pattern)) } + + static apply() { + Integer.mixin(TimeGrammar) + } +} diff --git a/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/monitoring/InMemoryConfiguration.groovy b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/monitoring/InMemoryConfiguration.groovy new file mode 100644 index 0000000..e62f4c1 --- /dev/null +++ b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/monitoring/InMemoryConfiguration.groovy @@ -0,0 +1,12 @@ +package pl.com.bottega.factory.shortages.prediction.monitoring + +import pl.com.bottega.factory.shortages.prediction.Configuration + +class InMemoryConfiguration implements Configuration { + int daysAhead; + + @Override + int shortagePredictionDaysAhead() { + return daysAhead; + } +} diff --git a/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageDiffPolicySpec.groovy b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageDiffPolicySpec.groovy new file mode 100644 index 0000000..ae5d0b3 --- /dev/null +++ b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/monitoring/ShortageDiffPolicySpec.groovy @@ -0,0 +1,128 @@ +package pl.com.bottega.factory.shortages.prediction.monitoring + +import pl.com.bottega.factory.shortages.prediction.Shortages +import pl.com.bottega.factory.shortages.prediction.calculation.TimeGrammar +import spock.lang.Specification + +import java.time.LocalDateTime + +class ShortageDiffPolicySpec extends Specification { + + def now = LocalDateTime.now() + + void setup() { + TimeGrammar.apply() + } + + def "'ValuesAreEquals policy' ignores time when shortage was found"() { + given: + def policy = ShortageDiffPolicy.ValuesAreEquals + + Shortages one = Shortages.builder("3009000", 0, now) + .add(now + 1.day, 500L) + .build().orElse(null) + + Shortages another = Shortages.builder("3009000", 0, now + 5.min) + .add(now + 1.day, 500L) + .build().orElse(null) + + expect: + !policy.areDifferent(one, another) + } + + def "'ValuesAreEquals policy' ignores current locked stock"() { + given: + def policy = ShortageDiffPolicy.ValuesAreEquals + + Shortages one = Shortages.builder("3009000", 0, now) + .add(now + 1.day, 500L) + .build().orElse(null) + + Shortages another = Shortages.builder("3009000", 1000, now) + .add(now + 1.day, 500L) + .build().orElse(null) + + expect: + !policy.areDifferent(one, another) + } + + def "'ValuesAreEquals policy' can NOT ignore product refNo"() { + given: + def policy = ShortageDiffPolicy.ValuesAreEquals + + Shortages one = Shortages.builder("3009000XXX", 0, now) + .add(now + 1.day, 500L) + .build().orElse(null) + + Shortages another = Shortages.builder("3009000", 0, now) + .add(now + 1.day, 500L) + .build().orElse(null) + + expect: + policy.areDifferent(one, another) + } + + def "'ValuesAreEquals policy' can NOT ignore shortage level diff"() { + given: + def policy = ShortageDiffPolicy.ValuesAreEquals + + Shortages one = Shortages.builder("3009000", 0, now) + .add(now + 1.day, 500L) + .build().orElse(null) + + Shortages another = Shortages.builder("3009000", 0, now) + .add(now + 1.day, 1500L) + .build().orElse(null) + + expect: + policy.areDifferent(one, another) + } + + def "'ValuesAreEquals policy' even minimal level diff is distinguished"() { + given: + def policy = ShortageDiffPolicy.ValuesAreEquals + + Shortages one = Shortages.builder("3009000", 0, now) + .add(now + 1.day, 500L) + .build().orElse(null) + + Shortages another = Shortages.builder("3009000", 0, now) + .add(now + 1.day, 499L) + .build().orElse(null) + + expect: + policy.areDifferent(one, another) + } + + def "'ValuesAreEquals policy' can NOT ignore shortage in different days"() { + given: + def policy = ShortageDiffPolicy.ValuesAreEquals + + Shortages one = Shortages.builder("3009000", 0, now) + .add(now + 1.day, 500L) + .build().orElse(null) + + Shortages another = Shortages.builder("3009000", 0, now) + .add(now + 2.day, 500L) + .build().orElse(null) + + expect: + policy.areDifferent(one, another) + } + + def "'ValuesAreEquals policy' can NOT ignore shortage in different time"() { + given: + def policy = ShortageDiffPolicy.ValuesAreEquals + + Shortages one = Shortages.builder("3009000", 0, now) + .add(now + 1.day, 500L) + .build().orElse(null) + + Shortages another = Shortages.builder("3009000", 0, now) + .add(now + 1.day + 1.min, 500L) + .build().orElse(null) + + expect: + policy.areDifferent(one, another) + } +} diff --git a/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/monitoring/ShortagePredictionProcessSpec.groovy b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/monitoring/ShortagePredictionProcessSpec.groovy new file mode 100644 index 0000000..a994e95 --- /dev/null +++ b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/monitoring/ShortagePredictionProcessSpec.groovy @@ -0,0 +1,179 @@ +package pl.com.bottega.factory.shortages.prediction.monitoring + +import pl.com.bottega.factory.shortages.prediction.Configuration +import pl.com.bottega.factory.shortages.prediction.Shortages +import pl.com.bottega.factory.shortages.prediction.calculation.Forecasts +import pl.com.bottega.factory.shortages.prediction.calculation.ShortagesCalculationAssembler +import spock.lang.Specification + +import java.time.LocalDateTime + +import static pl.com.bottega.factory.shortages.prediction.monitoring.NewShortage.After; + +class ShortagePredictionProcessSpec extends Specification { + + def refNo = "3009000" + def now = LocalDateTime.now() + def forecastAssembler = new ShortagesCalculationAssembler(refNo: refNo, now: now) + def events = Mock(ShortageEvents) + + def "Emits no events when there is still no shortages"() { + given: + def process = predictionProcess( + noShortagesWasPreviouslyFound(), + noShortagesWillBeFound() + ) + + when: + process.onDemandChanged() + + then: + 0 * events.emit(_ as NewShortage) + 0 * events.emit(_ as ShortageSolved) + } + + def "Emits NewShortage found when shortage was found first time"() { + given: + def process = predictionProcess( + noShortagesWasPreviouslyFound(), + willFindShortages(someShortages()) + ) + + when: + process.onDemandChanged() + + then: + 1 * events.emit(newShortage(After.DemandChanged, someShortages())) + 0 * events.emit(_ as ShortageSolved) + } + + def "Emits ShortageSolved when shortage disappear"() { + given: + def process = predictionProcess( + wasPreviouslyFound(someShortages()), + noShortagesWillBeFound() + ) + + when: + process.onDemandChanged() + + then: + 0 * events.emit(_ as NewShortage) + 1 * events.emit(shortageSolved()) + } + + def "Emits no events when there is still 'same' shortages"() { + given: + def process = predictionProcess( + wasPreviouslyFound(someShortages()), + willFindShortages(someShortages()) + ) + + when: + process.onDemandChanged() + + then: + 0 * events.emit(_ as NewShortage) + 0 * events.emit(_ as ShortageSolved) + } + + def "Emits NewShortage found when 'different' shortages will be found"() { + given: + def process = predictionProcess( + wasPreviouslyFound(someShortages()), + willFindShortages(someDifferentShortages()) + ) + + when: + process.onDemandChanged() + + then: + 1 * events.emit(newShortage(After.DemandChanged, someDifferentShortages())) + 0 * events.emit(_ as ShortageSolved) + } + + def "Remembers last found shortage"() { + given: + def process = predictionProcess( + noShortagesWasPreviouslyFound(), + willFindShortages(someShortages()) + ) + + when: + process.onDemandChanged() + + then: + 1 * events.emit(newShortage(After.DemandChanged, someShortages())) + 0 * events.emit(_ as ShortageSolved) + + when: + process.onDemandChanged() + process.onLockedParts() + process.onPlanChanged() + process.onStockChanged() + + then: + 0 * events.emit(_ as NewShortage) + 0 * events.emit(_ as ShortageSolved) + } + + ShortagePredictionProcess predictionProcess( + Shortages previouslyFound, + Forecasts forecastThatWillFindShortages) { + + new ShortagePredictionProcess( + refNo, + previouslyFound, + ShortageDiffPolicy.ValuesAreEquals, + forecastThatWillFindShortages, + defaultConfig(), + events + ) + } + + Map someShortages() { + [(now.plusHours(5)): 500L, + (now.plusDays(1)) : 500L] + } + + Map someDifferentShortages() { + [(now.plusHours(5)): 100L, + (now.plusDays(1)) : 900L] + } + + Shortages noShortagesWasPreviouslyFound() { + null + } + + Shortages wasPreviouslyFound(Map shortages) { + forecastAssembler.shortage(shortages).orElse(null) + } + + Forecasts noShortagesWillBeFound() { + forecastAssembler.forecastProvider( + forecastAssembler.stock(1000), + forecastAssembler.noDemands(), + forecastAssembler.noProductions() + ) + } + + Forecasts willFindShortages(Map shortages) { + forecastAssembler.forecastProvider( + forecastAssembler.stock(0), + forecastAssembler.demands(shortages), + forecastAssembler.noProductions() + ) + } + + Configuration defaultConfig() { + new InMemoryConfiguration(daysAhead: 14) + } + + NewShortage newShortage(After after, Map missing) { + new NewShortage(after, forecastAssembler.shortage(missing).get()) + } + + ShortageSolved shortageSolved() { + new ShortageSolved(refNo) + } +} diff --git a/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/notification/NotificationOfShortageSpec.groovy b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/notification/NotificationOfShortageSpec.groovy new file mode 100644 index 0000000..fbc3abc --- /dev/null +++ b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/notification/NotificationOfShortageSpec.groovy @@ -0,0 +1,135 @@ +package pl.com.bottega.factory.shortages.prediction.notification + +import pl.com.bottega.factory.shortages.prediction.Shortages +import pl.com.bottega.factory.shortages.prediction.monitoring.NewShortage +import pl.com.bottega.factory.shortages.prediction.monitoring.ShortageSolved +import spock.lang.Specification + +import java.time.* + +import static pl.com.bottega.factory.shortages.prediction.monitoring.NewShortage.After + +class NotificationOfShortageSpec extends Specification { + + def refNo = "3009000" + def now = LocalDateTime.now() + + def notifications = Mock(Notifications) + def tasks = Mock(QualityTasks) + def clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()) + def policy = RecoveryTaskPriorityChangePolicy.shortageInDays(2) + + def "Alerts planner after DemandChanged"() { + given: + def notificator = notificator() + + when: + notificator.emit(newShortage(After.DemandChanged, withShortage())) + + then: + 1 * notifications.alertPlanner(withShortage()) + } + + def "Warns planner after DemandChanged"() { + given: + def notificator = notificator() + + when: + notificator.emit(newShortage(After.LockedParts, withShortage())) + + then: + 1 * notifications.softNotifyPlanner(withShortage()) + } + + def "Marks shortage on plan during adjustment of plan"() { + given: + def notificator = notificator() + + when: + notificator.emit(newShortage(After.PlanChanged, withShortage())) + + then: + 1 * notifications.markOnPlan(withShortage()) + } + + def "Alerts planner after StockChanged"() { + given: + def notificator = notificator() + + when: + notificator.emit(newShortage(After.StockChanged, withShortage())) + + then: + 1 * notifications.alertPlanner(withShortage()) + } + + def "Increase task priority if there are locked parts and shortage is 'close'"() { + given: + def notificator = notificator() + + when: + notificator.emit(newShortage(After.StockChanged, + withShortage(Duration.ofDays(1), 500)) + ) + + then: + 1 * tasks.increasePriorityFor(refNo) + } + + def "Don't try to increase task priority if there are NO locked parts"() { + given: + def notificator = notificator() + + when: + notificator.emit(newShortage(After.StockChanged, + withShortage(Duration.ofDays(1), 0)) + ) + + then: + 0 * tasks.increasePriorityFor(_) + } + + def "Don't try to increase task priority if there is shortage in far future"() { + given: + def notificator = notificator() + + when: + notificator.emit(newShortage(After.StockChanged, + withShortage(Duration.ofDays(10), 500)) + ) + + then: + 0 * tasks.increasePriorityFor(_) + } + + def "No notification after shortage solved specified for now"() { + given: + def notificator = notificator() + + when: + notificator.emit(new ShortageSolved(refNo)) + + then: + 0 * tasks.increasePriorityFor(_) + 0 * notifications._(_) + } + + def notificator() { + new NotificationOfShortage( + tasks, clock, policy, + NotificationOfShortage.rulesOfPlannerNotification(notifications) + ) + } + + NewShortage newShortage(After after, Shortages shortages) { + new NewShortage(after, shortages) + } + + Shortages withShortage( + Duration firstShortageIn = Duration.ofDays(4), + long lockedStock = 0) { + Shortages.builder(refNo, lockedStock, now) + .add(now.plus(firstShortageIn), 500L) + .build().get() + } +} diff --git a/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/notification/RecoveryTaskPriorityChangePolicySpec.groovy b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/notification/RecoveryTaskPriorityChangePolicySpec.groovy new file mode 100644 index 0000000..1bd3fa6 --- /dev/null +++ b/shortages-prediction-model/src/test/groovy/pl/com/bottega/factory/shortages/prediction/notification/RecoveryTaskPriorityChangePolicySpec.groovy @@ -0,0 +1,76 @@ +package pl.com.bottega.factory.shortages.prediction.notification + +import pl.com.bottega.factory.shortages.prediction.Shortages +import spock.lang.Specification + +import java.time.Duration +import java.time.LocalDateTime + +class RecoveryTaskPriorityChangePolicySpec extends Specification { + + def now = LocalDateTime.now() + + Shortages foundShortage(Duration firstShortageIn, long lockedStock) { + Shortages.builder("3009000", lockedStock, now) + .add(now.plus(firstShortageIn), 500L) + .build().get() + } + + def "'never policy' don't increase priority... ever"() { + given: + def policy = RecoveryTaskPriorityChangePolicy.never() + + expect: + policy.shouldIncreasePriority( + now, + foundShortage(firstShortageIn, lockedStock) + ) == policyDecision + + where: + firstShortageIn | lockedStock || policyDecision + Duration.ofMinutes(5) | 500L || false + Duration.ofMinutes(5) | 0L || false + Duration.ofDays(15) | 500L || false + Duration.ofDays(15) | 0L || false + } + + def "'onlyIn1DaysAhead policy' increase priority for shortages in 24h"() { + given: + def policy = RecoveryTaskPriorityChangePolicy.onlyIn1DaysAhead() + + expect: + policy.shouldIncreasePriority( + now, + foundShortage(firstShortageIn, lockedStock) + ) == policyDecision + + where: + firstShortageIn | lockedStock || policyDecision + Duration.ofMinutes(5) | 500L || true + Duration.ofMinutes(5) | 0L || false + + Duration.ofHours(24).minusMillis(1) | 500L || true + Duration.ofHours(24) | 500L || false + Duration.ofHours(24).plusMillis(1) | 500L || false + } + + def "'shortageInDays(2) policy' increase priority for shortages in 48h"() { + given: + def policy = RecoveryTaskPriorityChangePolicy.shortageInDays(2) + + expect: + policy.shouldIncreasePriority( + now, + foundShortage(firstShortageIn, lockedStock) + ) == policyDecision + + where: + firstShortageIn | lockedStock || policyDecision + Duration.ofMinutes(5) | 500L || true + Duration.ofMinutes(5) | 0L || false + + Duration.ofHours(48).minusMillis(1) | 500L || true + Duration.ofHours(48) | 500L || false + Duration.ofHours(48).plusMillis(1) | 500L || false + } +}