Location.java

/*
 * Copyright 2005-2024 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.openwms.common.location;

import jakarta.persistence.AttributeOverride;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.openwms.common.account.Account;
import org.openwms.common.app.Default;
import org.springframework.util.Assert;

import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;

import static java.lang.String.format;
import static org.openwms.common.location.StringListConverter.STRING_LIST_LENGTH;

/**
 * A Location, represents a physical or virtual place in a warehouse. Could be something like a storage location in the stock or a conveyor
 * location. Even error locations can be represented with the Location. Multiple Locations with same characteristics are grouped to a
 * {@link LocationGroup}.
 *
 * @author Heiko Scherrer
 * @GlossaryTerm
 * @see org.openwms.common.location.LocationGroup
 */
@Entity
@Table(name = Location.TABLE, uniqueConstraints = {
        @UniqueConstraint(name = "UC_LOC_ID", columnNames = {"C_AREA", "C_AISLE", "C_X", "C_Y", "C_Z"}),
        @UniqueConstraint(name = "UC_LOC_PLC_CODE", columnNames = "C_PLC_CODE"),
        @UniqueConstraint(name = "UC_LOC_ERP_CODE", columnNames = "C_ERP_CODE")
})
public class Location extends Target implements Serializable {

    /** Table name. */
    public static final String TABLE = "COM_LOCATION";
    private static final String CREATION_OF_LOCATION_WITH_LOCATION_ID_NULL = "Creation of Location with locationId null";

    /** Unique natural key. */
    @Embedded
    @NotNull
    @AttributeOverride(name = "area", column = @Column(name = "C_AREA"))
    @AttributeOverride(name = "aisle", column = @Column(name = "C_AISLE"))
    @AttributeOverride(name = "x", column = @Column(name = "C_X"))
    @AttributeOverride(name = "y", column = @Column(name = "C_Y"))
    @AttributeOverride(name = "z", column = @Column(name = "C_Z"))
    private LocationPK locationId;

    /** The {@code Location} might be assigned to an {@link Account}. */
    @ManyToOne
    @JoinColumn(name = "C_ACCOUNT", referencedColumnName = "C_IDENTIFIER", foreignKey = @ForeignKey(name = "FK_LOC_ACC"))
    private Account account;

    /** PLC code of the {@code Location}. */
    @Column(name = "C_PLC_CODE")
    private String plcCode;

    /** ERP code of the {@code Location}. */
    @Column(name = "C_ERP_CODE", unique = true)
    private String erpCode;

    /** Description of the {@code Location}. */
    @Column(name = "C_DESCRIPTION")
    @Size(max = 255)
    private String description;

    /** Sort order index used by strategies for putaway, or picking. */
    @Column(name = "C_SORT")
    private Integer sortOrder;

    /** Might be assigned to a particular zone in stock. */
    @Column(name = "C_STOCK_ZONE")
    private String stockZone;

    /** A {@code Location} can be assigned to a particular labels. */
    @Column(name="C_LABELS", length = STRING_LIST_LENGTH)
    @Convert(converter = StringListConverter.class)
    @Size(max = STRING_LIST_LENGTH)
    private List<String> labels;

    /** Maximum number of {@code TransportUnit}s allowed on the {@code Location}. */
    @Column(name = "C_NO_MAX_TRANSPORT_UNITS")
    private int noMaxTransportUnits = DEF_MAX_TU;
    /** Default value of {@link #noMaxTransportUnits}. */
    public static final int DEF_MAX_TU = 1;

    /** Maximum allowed weight on the {@code Location}. */
    @Column(name = "C_MAXIMUM_WEIGHT")
    private BigDecimal maximumWeight;

    /**
     * Date of last movement. When a {@code TransportUnit} is moving to or away from the {@code Location}, {@code lastMovement} is updated.
     * This is useful to get the history of {@code TransportUnit}s as well as for inventory calculation.
     */
    @Column(name = "C_LAST_MOVEMENT")
    private LocalDateTime lastMovement;

    /**
     * Shall the {@code Location} be included in the calculation of {@code TransportUnit}s of the parent {@link LocationGroup}.
     * <ul>
     *     <li>{@literal true} : {@code Location} is included in calculation of {@code TransportUnit}s.</li>
     *     <li>{@literal false}: {@code Location} is not included in calculation of {@code TransportUnit}s.</li>
     * </ul>
     */
    @Column(name = "C_LG_COUNTING_ACTIVE")
    private Boolean locationGroupCountingActive = DEF_LG_COUNTING_ACTIVE;
    /** Default value of {@link #locationGroupCountingActive}. */
    public static final boolean DEF_LG_COUNTING_ACTIVE = false;

    /**
     * Signals the incoming state of the {@code Location}.
     * {@code Location}s which are blocked for incoming movements do not accept {@code TransportUnit}s.
     * <ul>
     *     <li>{@literal true} : {@code Location} is ready to pick up {@code TransportUnit}s.</li>
     *     <li>{@literal false}: {@code Location} is locked, and cannot pick up {@code TransportUnit}s.</li>
     * </ul>
     */
    @Column(name = "C_INCOMING_ACTIVE")
    private boolean incomingActive = DEF_INCOMING_ACTIVE;
    /** Default value of {@link #incomingActive}. */
    public static final boolean DEF_INCOMING_ACTIVE = true;

    /**
     * Signals the outgoing state of the {@code Location}.
     * {@code Location}s which are blocked for outgoing do not accept to move {@code TransportUnit}s away.
     * <ul>
     *     <li>{@literal true} : {@code Location} is enabled for outgoing {@code TransportUnit}s.</li>
     *     <li>{@literal false}: {@code Location} is locked, {@code TransportUnit}s can't leave the {@code Location}.</li>
     * </ul>
     */
    @Column(name = "C_OUTGOING_ACTIVE")
    private boolean outgoingActive = DEF_OUTGOING_ACTIVE;
    /** Default value of {@link #outgoingActive}. */
    public static final boolean DEF_OUTGOING_ACTIVE = true;

    /**
     * The PLC is able to change the state of a {@code Location}. This property stores the last state, received from the PLC.
     * <ul>
     *     <li>0 : No PLC error, everything okay</li>
     *     <li>&lt; 0: Not defined</li>
     *     <li>&gt; 0: Some defined error code</li>
     * </ul>
     */
    @Column(name = "C_PLC_STATE")
    private int plcState = DEF_PLC_STATE;
    /** Default value of {@link #plcState}. */
    public static final int DEF_PLC_STATE = 0;

    /**
     * Determines whether the {@code Location} is considered in the allocation procedure.
     * <ul>
     *     <li>{@literal true} : The {@code Location} is considered in storage calculation by an allocation procedure.</li>
     *     <li>{@literal false} : The {@code Location} is not considered in the allocation process.</li>
     * </ul>
     */
    @Column(name = "C_CONSIDERED_IN_ALLOCATION")
    private Boolean consideredInAllocation = DEF_CONSIDERED_IN_ALLOCATION;
    /** Default value of {@link #consideredInAllocation}. */
    public static final boolean DEF_CONSIDERED_IN_ALLOCATION = true;

    /** The {@link LocationType} the {@code Location} belongs to. */
    @ManyToOne
    @JoinColumn(name = "C_LOCATION_TYPE", foreignKey = @ForeignKey(name = "FK_LOC_LT"))
    private LocationType locationType;

    /** Some group the {@code Location} belongs to. */
    @Column(name = "C_GROUP")
    private String group;

    /** The {@code Location} may be classified, like 'hazardous'. */
    @Column(name = "C_CLASSIFICATION")
    @Size(max = 255)
    private String classification;

    /** The {@link LocationGroup} the {@code Location} belongs to. */
    @ManyToOne
    @JoinColumn(name = "C_LOCATION_GROUP", foreignKey = @ForeignKey(name = "FK_LOC_LG"))
    private LocationGroup locationGroup;

    /** Stored {@link Message}s on the {@code Location}. */
    @OneToMany(cascade = {CascadeType.ALL})
    @JoinTable(name = "COM_LOCATION_MESSAGE",
            uniqueConstraints = @UniqueConstraint(name = "UC_LOCM_ID", columnNames = "C_MESSAGE_ID"),
            joinColumns = @JoinColumn(name = "C_LOCATION_ID", foreignKey = @ForeignKey(name = "FK_LOCM_LOCPK")),
            inverseJoinColumns = @JoinColumn(name = "C_MESSAGE_ID", foreignKey = @ForeignKey(name = "FK_LOCM_MSGPK"))
    )
    private Set<Message> messages = new HashSet<>();

    /*~ ----------------------------- constructors ------------------- */

    /**
     * Create a new Location with the business key.
     *
     * @param locationId The unique natural key of the Location
     */
    protected Location(LocationPK locationId) {
        Assert.notNull(locationId, CREATION_OF_LOCATION_WITH_LOCATION_ID_NULL);
        this.locationId = locationId;
    }

    /**
     * Create a new Location.
     *
     * @param locationId The unique natural key of the Location
     * @param locationGroup The LocationGroup the Location belongs to
     */
    @Default
    Location(LocationPK locationId, Account account, LocationGroup locationGroup, LocationType locationType, String erpCode,
            String plcCode, Integer sortOrder, String stockZone) {
        Assert.notNull(locationId, CREATION_OF_LOCATION_WITH_LOCATION_ID_NULL);
        this.locationId = locationId;
        this.account = account;
        this.locationGroup = locationGroup;
        this.locationType = locationType;
        this.erpCode = erpCode;
        this.plcCode = plcCode;
        this.sortOrder = sortOrder;
        this.stockZone = stockZone;
    }

    /** Dear JPA... */
    protected Location() { }

    /**
     * Create a new Location with the business key.
     *
     * @param locationId The unique natural key of the Location
     * @return The Location
     */
    public static Location create(LocationPK locationId) {
        return new Location(locationId);
    }
    /*~ ----------------------------- methods ------------------- */
    /** Required for the Mapper. */
    @Override
    public void setPersistentKey(String pKey) {
        super.setPersistentKey(pKey);
    }

    /**
     * Check if the Location has a {@code locationId} set.
     *
     * @return {@literal true} if so
     */
    public boolean hasLocationId() {
        return locationId != null;
    }

    /**
     * Return the {@link Account} this {@code Location} is assigned to.
     *
     * @return The Account
     */
    public Account getAccount() {
        return account;
    }

    /**
     * Get the ERP Code of the Location.
     *
     * @return The ERP code
     */
    public String getErpCode() {
        return erpCode;
    }

    /**
     * Get the PLC Code of the Location.
     *
     * @return The PLC code
     */
    public String getPlcCode() {
        return plcCode;
    }

    /**
     * Add a new {@link Message} to this Location.
     *
     * @param message The {@link Message} to be added
     * @return {@literal true} if the {@link Message} is new in the collection of messages, otherwise {@literal false}
     */
    public boolean addMessage(Message message) {
        Assert.notNull(message, "null passed to addMessage, this: " + this);
        return this.messages.add(message);
    }

    /**
     * Determine whether the Location is considered during allocation.
     *
     * @return {@literal true} when considered in allocation, otherwise {@literal false}
     */
    public boolean isConsideredInAllocation() {
        return this.consideredInAllocation;
    }

    /**
     * Returns the description of the Location.
     *
     * @return The description text
     */
    public String getDescription() {
        return this.description;
    }

    /**
     * Set the description text of the Location.
     *
     * @param description The description text
     */
    public void setDescription(String description) {
        this.description = description;
    }

    /**
     * Get the sortOrder.
     *
     * @return A sequence number
     */
    public Integer getSortOrder() {
        return sortOrder;
    }

    /**
     * Returns the stockZone.
     *
     * @return As string
     */
    public String getStockZone() {
        return stockZone;
    }

    /**
     * Returns the list of Strings set as labels for the Location.
     *
     * @return A list of Strings or an empty list
     */
    public List<String> getLabels() {
        return labels;
    }

    /**
     * Set a list of labels to the Location.
     *
     * @param labels A comma-separated list of labels
     */
    public void setLabels(List<String> labels) {
        this.labels = labels;
    }

    /**
     * Determine whether incoming mode is activated and {@code TransportUnit}s can be put on this Location.
     *
     * @return {@literal true} when incoming mode is activated, otherwise {@literal false}
     */
    public boolean isInfeedActive() {
        return this.incomingActive;
    }

    /**
     * Set the incoming mode of this Location.
     *
     * @param infeedActive {@literal true} means Infeed movements are possible, {@literal false} means Infeed movements are blocked
     */
    public void setInfeed(boolean infeedActive) {
        this.incomingActive = infeedActive;
    }

    /**
     * Check whether infeed is blocked and moving {@code TransportUnit}s to here is forbidden.
     *
     * @return {@literal true} is blocked, otherwise {@literal false}
     */
    public boolean isInfeedBlocked() {
        return !this.incomingActive;
    }

    /**
     * Return the date when the Location was updated the last time.
     *
     * @return Timestamp of the last update
     */
    public LocalDateTime getLastMovement() {
        return this.lastMovement;
    }

    /**
     * Change the date when a TransportUnit was put or left the Location the last time.
     *
     * @param lastMovement The date of change.
     */
    public void setLastMovement(LocalDateTime lastMovement) {
        this.lastMovement = lastMovement;
    }

    /**
     * Return the {@link LocationGroup} where the Location belongs to.
     *
     * @return The {@link LocationGroup} of the Location
     */
    public LocationGroup getLocationGroup() {
        return this.locationGroup;
    }

    /**
     * Determine whether the Location is part of the parent {@link LocationGroup}s calculation procedure of {@code TransportUnit}s.
     *
     * @return {@literal true} if calculation is activated, otherwise {@literal false}
     */
    public boolean isLocationGroupCountingActive() {
        return this.locationGroupCountingActive;
    }

    /**
     * Returns the locationId (natural key) of the Location.
     *
     * @return The locationId
     */
    public LocationPK getLocationId() {
        return this.locationId;
    }

    /**
     * Returns the type of Location.
     *
     * @return The type
     */
    public LocationType getLocationType() {
        return this.locationType;
    }

    public void setLocationType(LocationType locationType) {
        if (this.locationType != null && !this.locationType.equals(locationType)) {
            throw new IllegalArgumentException(format("LocationType of Location [%s] is already defined and can't be changed", locationType));
        }
        this.locationType = locationType;
    }

    /**
     * Returns the group the Location belongs to.
     *
     * @return The group as String
     */
    public String getGroup() {
        return group;
    }

    /**
     * Returns the classification of the Location.
     *
     * @return As a String
     */
    public String getClassification() {
        return classification;
    }

    /**
     * Set the classification.
     *
     * @param classification As an arbitrary String
     */
    public void setClassification(String classification) {
        this.classification = classification;
    }

    /**
     * Return the maximum allowed weight on the Location.
     *
     * @return The maximum allowed weight
     */
    public BigDecimal getMaximumWeight() {
        return this.maximumWeight;
    }

    /**
     * Returns an unmodifiable Set of {@link Message}s stored for the Location.
     *
     * @return An unmodifiable Set
     */
    public Set<Message> getMessages() {
        return new HashSet<>(messages);
    }

    /**
     * Returns the maximum number of {@code TransportUnit}s allowed on the Location.
     *
     * @return The maximum number of {@code TransportUnit}s
     */
    public int getNoMaxTransportUnits() {
        return noMaxTransportUnits;
    }

    /**
     * Determine whether outgoing mode is activated and {@code TransportUnit}s can leave this Location.
     *
     * @return {@literal true} when outgoing mode is activated, otherwise {@literal false}
     */
    public boolean isOutfeedActive() {
        return this.outgoingActive;
    }

    /**
     * Check whether outfeed is blocked and moving {@code TransportUnit}s from here is forbidden.
     *
     * @return {@literal true} is blocked, otherwise {@literal false}
     */
    public boolean isOutfeedBlocked() {
        return !this.outgoingActive;
    }

    /**
     * Set the outfeed mode of this Location.
     *
     * @param outfeedActive {@literal true} means Outfeed movements are possible, {@literal false} means Outfeed movements are blocked
     */
    public void setOutfeed(boolean outfeedActive) {
        this.outgoingActive = outfeedActive;
    }


    /**
     * Return the current set plc state.
     *
     * @return the plc state
     */
    public int getPlcState() {
        return plcState;
    }

    /**
     * Set the plc state.
     *
     * @param plcState the plc state
     */
    public void setPlcState(int plcState) {
        this.plcState = plcState;
    }

    /**
     * Remove one or more {@link Message}s from this Location.
     *
     * @param msgs An array of {@link Message}s to be removed
     * @return {@literal true} if the {@link Message}s were found and removed, otherwise {@literal false}
     * @throws IllegalArgumentException when messages is {@literal null}
     */
    public boolean removeMessages(Message... msgs) {
        Assert.notNull(msgs, () -> "null passed to removeMessages, this: " + this);
        return this.messages.removeAll(Arrays.asList(msgs));
    }

    /**
     * Add this {@code Location} to the {@literal locationGroup}. When the argument is {@literal null} an existing {@link LocationGroup} is
     * removed from the {@code Location}.
     *
     * @param locationGroup The {@link LocationGroup} to be assigned
     */
    void setLocationGroup(LocationGroup locationGroup) {
        Assert.notNull(locationGroup, () -> "Not allowed to call location#setLocationGroup with null argument, this: " + this);
        if (this.locationGroup != null) {
            this.locationGroup.removeLocation(this);
        }
        this.setLocationGroupCountingActive(locationGroup.isLocationGroupCountingActive());
        this.locationGroup = locationGroup;
    }

    /**
     * Define whether or not the Location shall be considered in counting {@code TransportUnit}s of the parent {@link LocationGroup}.
     *
     * @param locationGroupCountingActive {@literal true} if considered, otherwise {@literal false}
     */
    public void setLocationGroupCountingActive(boolean locationGroupCountingActive) {
        this.locationGroupCountingActive = locationGroupCountingActive;
    }

    /**
     * Checks whether this {@code Location} belongs to a {@code LocationGroup}.
     *
     * @return {@literal true} if it belongs to a {@code LocationGroup}, otherwise {@literal false}
     */
    public boolean belongsToLocationGroup() {
        return locationGroup != null;
    }

    /**
     * Checks whether this {@code Location} belongs NOT to a {@code LocationGroup}.
     *
     * @return {@literal true} if it does not belong to a {@code LocationGroup}, otherwise {@literal false}
     */
    public boolean belongsNotToLocationGroup() {
        return !belongsToLocationGroup();
    }

    /**
     * Set the locationGroup to {@literal null}.
     */
    void unsetLocationGroup() {
        this.locationGroup = null;
    }

    /**
     * {@inheritDoc}
     * <p>
     * Only use the unique natural key for comparison.
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Location location = (Location) o;
        return Objects.equals(locationId, location.locationId);
    }

    /**
     * {@inheritDoc}
     * <p>
     * Only use the unique natural key for hashCode calculation.
     */
    @Override
    public int hashCode() {
        return Objects.hash(locationId);
    }

    /**
     * Return the {@link LocationPK} as String.
     *
     * @return String locationId
     * @see LocationPK#toString()
     */
    @Override
    public String toString() {
        return locationId.toString();
    }


    public static final class LocationBuilder {
        private final Location target;

        private LocationBuilder(Location target) {
            this.target = target;
        }

        public static LocationBuilder aLocation(Location target) {
            return new LocationBuilder(target);
        }

        public LocationBuilder withAccount(Account account) {
            this.target.account = account;
            return this;
        }

        public LocationBuilder withPlcCode(String plcCode) {
            this.target.plcCode = plcCode;
            return this;
        }

        public LocationBuilder withErpCode(String erpCode) {
            this.target.erpCode = erpCode;
            return this;
        }

        public LocationBuilder withDescription(String description) {
            this.target.description = description;
            return this;
        }

        public LocationBuilder withSortOrder(Integer sortOrder) {
            this.target.sortOrder = sortOrder;
            return this;
        }

        public LocationBuilder withStockZone(String stockZone) {
            this.target.stockZone = stockZone;
            return this;
        }

        public LocationBuilder withLabels(List<String> labels) {
            this.target.labels = labels;
            return this;
        }

        public LocationBuilder withNoMaxTransportUnits(int noMaxTransportUnits) {
            this.target.noMaxTransportUnits = noMaxTransportUnits;
            return this;
        }

        public LocationBuilder withMaximumWeight(BigDecimal maximumWeight) {
            this.target.maximumWeight = maximumWeight;
            return this;
        }

        public LocationBuilder withLastMovement(LocalDateTime lastMovement) {
            this.target.lastMovement = lastMovement;
            return this;
        }

        public LocationBuilder withLocationGroupCountingActive(boolean locationGroupCountingActive) {
            this.target.locationGroupCountingActive = locationGroupCountingActive;
            return this;
        }

        public LocationBuilder withIncomingActive(boolean incomingActive) {
            this.target.incomingActive = incomingActive;
            return this;
        }

        public LocationBuilder withOutgoingActive(boolean outgoingActive) {
            this.target.outgoingActive = outgoingActive;
            return this;
        }

        public LocationBuilder withPlcState(int plcState) {
            this.target.plcState = plcState;
            return this;
        }

        public LocationBuilder withConsideredInAllocation(boolean consideredInAllocation) {
            this.target.consideredInAllocation = consideredInAllocation;
            return this;
        }

        public LocationBuilder withLocationType(LocationType locationType) {
            this.target.locationType = locationType;
            return this;
        }

        public LocationBuilder withGroup(String group) {
            this.target.group = group;
            return this;
        }

        public LocationBuilder withClassification(String classification) {
            this.target.classification = classification;
            return this;
        }

        public LocationBuilder withLocationGroup(LocationGroup locationGroup) {
            this.target.locationGroup = locationGroup;
            return this;
        }

        public LocationBuilder withMessages(Set<Message> messages) {
            this.target.messages = messages;
            return this;
        }

        public Location build() {
            return target;
        }
    }
}