domain model and empty database

This commit is contained in:
Michał Michaluk
2017-12-04 09:00:27 +01:00
parent a2a5e80a3f
commit b4c706b117
64 changed files with 3184 additions and 1 deletions

7
.gitattributes vendored Normal file
View File

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

88
.gitignore vendored Normal file
View File

@@ -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
*~

View File

@@ -1 +1 @@
# factory
# production

17
database/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM postgres:10
MAINTAINER Michał Michaluk <michal.michaluk@bottega.com.pl>
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

4
database/initdb.sql Normal file
View File

@@ -0,0 +1,4 @@
--create schema demands authorization postgres;
--create schema shortages authorization postgres;
--create schema products authorization postgres;

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.4.xsd">
<!--<include file="/schema/product-management.sql"/>-->
<!--<include file="/schema/demand-forecasting.sql"/>-->
<!--<include file="/schema/shortages-prediction.sql"/>-->
</databaseChangeLog>

View File

@@ -0,0 +1,3 @@
--liquibase formatted sql
--changeset michaluk.michal:1.init

View File

@@ -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 "$@"

View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>pl.com.bottega</groupId>
<artifactId>demand-forecasting-model</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.7.0</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<compilerArgument>-parameters</compilerArgument>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>1.6</version>
<executions>
<execution>
<goals>
<goal>compileTests</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.20.1</version>
<configuration>
<useFile>false</useFile>
<includes>
<include>**/*Spec.java</include>
<include>**/*Test.java</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>pl.com.bottega</groupId>
<artifactId>shared-kernel-model</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.18</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.8.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>info.cukes</groupId>
<artifactId>cucumber-java</artifactId>
<version>1.2.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>info.cukes</groupId>
<artifactId>cucumber-core</artifactId>
<version>1.2.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.1-groovy-2.4</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -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<Delivery> deliveriesFor(String refNo, LocalDate date, Demand demand);
}

View File

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

View File

@@ -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<Demand.Schema, DeliveriesSuggestion> policies;
public Stream<Delivery> propose(LocalDate date, Demand demand) {
return policies.getOrDefault(demand.getSchema(), DeliveriesSuggestion.DUMMY)
.deliveriesFor(refNo, date, demand);
}
}

View File

@@ -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<LocalDate, Adjustment> adjustments;
public AdjustDemand(String refNo,
Map<LocalDate, Adjustment> adjustments) {
this.refNo = refNo;
this.adjustments = Collections.unmodifiableMap(adjustments);
}
public void forEachStartingFrom(LocalDate date, BiConsumer<LocalDate, Adjustment> f) {
adjustments.entrySet().stream()
.filter(e -> !e.getKey().isBefore(date))
.forEach(e -> f.accept(e.getKey(), e.getValue()));
}
}

View File

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

View File

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

View File

@@ -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<LocalDate, DailyDemand> fetched = new HashMap<>();
final List<DailyDemand.LevelChanged> changes = new ArrayList<>();
final List<DailyDemand.DemandUpdated> updates = new ArrayList<>();
final List<DailyDemand.ReviewRequest> warnings = new ArrayList<>();
Function<LocalDate, DailyDemand> fetch;
@Override
public DailyDemand get(LocalDate date) {
return fetched.computeIfAbsent(date, fetch);
}
@Override
public List<DailyDemand.LevelChanged> getChanges() {
return unmodifiableList(changes);
}
@Override
public List<DailyDemand.DemandUpdated> getUpdates() {
return unmodifiableList(updates);
}
@Override
public List<DailyDemand.ReviewRequest> 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<DailyId, DemandEvents.DemandedLevelsChanged.Change> changes() {
return getChanges().stream().collect(Collectors.toMap(
DailyDemand.LevelChanged::getId, DailyDemand.LevelChanged::getChange));
}
}

View File

@@ -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<LocalDate, Demand> demands;
public Document(Instant created, String refNo, SortedMap<LocalDate, Demand> demands) {
this.created = created;
this.refNo = refNo;
this.demands = Collections.unmodifiableSortedMap(demands);
}
public void forEachStartingFrom(LocalDate date, BiConsumer<LocalDate, Demand> f) {
demands.entrySet().stream()
.filter(e -> !e.getKey().isBefore(date))
.forEach(e -> f.accept(e.getKey(), e.getValue()));
}
}

View File

@@ -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<LevelChanged> getChanges();
List<DemandUpdated> getUpdates();
List<ReviewRequest> getReviewRequests();
boolean anyChanges();
Map<DailyId, Change> 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<LocalDate, Demand> 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<LocalDate, Long> adjustments) {
Map<LocalDate, Adjustment> results = new HashMap<>()
adjustments.forEach { date, level ->
results.put(date, Adjustment.week(Demand.of(level)))
}
new AdjustDemand(refNo, results)
}
DemandedLevelsChanged levelChanged(List<Long>... changes) {
def date = LocalDate.now(clock)
Map<DailyId, Change> 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)
}
}

View File

@@ -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<LocalDate, DailyDemand> fetched,
Function<LocalDate, DailyDemand> factory) {
super();
this.fetched.putAll(fetched);
this.fetch = factory;
}
public void clearUnitOfWork() {
this.changes.clear();
this.warnings.clear();
}
}

View File

@@ -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 (DONT 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 |

View File

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

View File

@@ -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 (DONT 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).

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
version: "2"
volumes:
data:
driver: local
services:
db:
build: ./database/
volumes:
- data:/var/lib/postgresql/data
ports:
- "5432:5432"

20
pom.xml Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>pl.com.bottega</groupId>
<artifactId>factory</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<modules>
<!--<module>app-monolith</module>-->
<module>shared-kernel-model</module>
<module>demand-forecasting-model</module>
<module>shortages-prediction-model</module>
<!--<module>playground-model</module>-->
</modules>
</project>

View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>pl.com.bottega</groupId>
<artifactId>shared-kernel-model</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.7.0</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<compilerArgument>-parameters</compilerArgument>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>1.6</version>
<executions>
<execution>
<goals>
<goal>compileTests</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.20.1</version>
<configuration>
<useFile>false</useFile>
<includes>
<include>**/*Spec.java</include>
<include>**/*Test.java</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.18</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.8.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>info.cukes</groupId>
<artifactId>cucumber-java</artifactId>
<version>1.2.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>info.cukes</groupId>
<artifactId>cucumber-core</artifactId>
<version>1.2.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.1-groovy-2.4</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

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

View File

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

View File

@@ -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<DailyId, Change> results;
public DemandedLevelsChanged(RefNoId id, Map<DailyId, Change> results) {
this.id = id;
this.results = Collections.unmodifiableMap(results);
}
@Value
public static class Change {
Demand previous;
Demand current;
}
}
}

View File

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

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>pl.com.bottega</groupId>
<artifactId>shortages-prediction-model</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.7.0</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<compilerArgument>-parameters</compilerArgument>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>1.6</version>
<executions>
<execution>
<goals>
<goal>compileTests</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.20.1</version>
<configuration>
<useFile>false</useFile>
<includes>
<include>**/*Spec.java</include>
<include>**/*Test.java</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>pl.com.bottega</groupId>
<artifactId>shared-kernel-model</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.18</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.8.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>info.cukes</groupId>
<artifactId>cucumber-java</artifactId>
<version>1.2.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>info.cukes</groupId>
<artifactId>cucumber-core</artifactId>
<version>1.2.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.1-groovy-2.4</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,8 @@
package pl.com.bottega.factory.shortages.prediction;
/**
* Created by michal on 02.02.2017.
*/
public interface Configuration {
int shortagePredictionDaysAhead();
}

View File

@@ -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.
* <p>
* 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<LocalDateTime, Long> 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<LocalDateTime, Long> gaps = new TreeMap<>();
public Builder add(LocalDateTime time, long level) {
gaps.put(time, Math.abs(level));
return this;
}
public Optional<Shortages> build() {
if (gaps.isEmpty()) {
return Optional.empty();
} else {
return Optional.of(new Shortages(refNo, locked, found,
Collections.unmodifiableSortedMap(gaps)));
}
}
}
}

View File

@@ -0,0 +1,9 @@
package pl.com.bottega.factory.shortages.prediction.calculation;
import lombok.Value;
@Value
public class CurrentStock {
long level;
long locked;
}

View File

@@ -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<LocalDateTime, Long> demands;
long get(LocalDateTime time) {
return demands.getOrDefault(time, 0L);
}
}

View File

@@ -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<LocalDateTime> times;
CurrentStock stock;
ProductionOutputs outputs;
Demands demands;
public Optional<Shortages> 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();
}
}

View File

@@ -0,0 +1,5 @@
package pl.com.bottega.factory.shortages.prediction.calculation;
public interface Forecasts {
Forecast get(String refNo, int daysAhead);
}

View File

@@ -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<Item> items;
ProductionOutputs outputsInTimes(LocalDateTime now, Set<LocalDateTime> 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);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package pl.com.bottega.factory.shortages.prediction.monitoring;
public interface ShortageEvents {
void emit(NewShortage event);
void emit(ShortageSolved event);
}

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
package pl.com.bottega.factory.shortages.prediction.monitoring;
import lombok.Value;
@Value
public class ShortageSolved {
String refNo;
}

View File

@@ -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<After, Notificator> rules;
Notificator otherwise;
Notificator wayOfNotificationAfter(After trigger) {
return rules.getOrDefault(trigger, otherwise);
}
}
interface Notificator {
void notifyAbout(Shortages shortages);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
package pl.com.bottega.factory.shortages.prediction.calculation
class ShortagesCalculationAssembler implements ShortagesCalculationAssemblerTrait {
}

View File

@@ -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<LocalDateTime> 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<ProductionForecast.Item> 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<LocalDateTime, Long> 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<Shortages> noShortages() {
Optional.empty()
}
Optional<Shortages> shortage(Map<LocalDateTime, Long> missing, long locked = 0) {
def shortages = Shortages.builder(refNo, locked, now)
missing.each { time, level -> shortages.add(time, level) }
shortages.build()
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<LocalDateTime, Long> someShortages() {
[(now.plusHours(5)): 500L,
(now.plusDays(1)) : 500L]
}
Map<LocalDateTime, Long> someDifferentShortages() {
[(now.plusHours(5)): 100L,
(now.plusDays(1)) : 900L]
}
Shortages noShortagesWasPreviouslyFound() {
null
}
Shortages wasPreviouslyFound(Map<LocalDateTime, Long> shortages) {
forecastAssembler.shortage(shortages).orElse(null)
}
Forecasts noShortagesWillBeFound() {
forecastAssembler.forecastProvider(
forecastAssembler.stock(1000),
forecastAssembler.noDemands(),
forecastAssembler.noProductions()
)
}
Forecasts willFindShortages(Map<LocalDateTime, Long> shortages) {
forecastAssembler.forecastProvider(
forecastAssembler.stock(0),
forecastAssembler.demands(shortages),
forecastAssembler.noProductions()
)
}
Configuration defaultConfig() {
new InMemoryConfiguration(daysAhead: 14)
}
NewShortage newShortage(After after, Map<LocalDateTime, Long> missing) {
new NewShortage(after, forecastAssembler.shortage(missing).get())
}
ShortageSolved shortageSolved() {
new ShortageSolved(refNo)
}
}

View File

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

View File

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