PackagingUnit.java

/*
 * Copyright 2005-2025 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.wms.inventory;

import jakarta.persistence.AttributeOverride;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapKeyColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import jakarta.persistence.Temporal;
import jakarta.persistence.TemporalType;
import jakarta.persistence.Transient;
import jakarta.validation.Valid;
import jakarta.validation.ValidationException;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.ameba.integration.jpa.ApplicationEntity;
import org.ameba.integration.jpa.BaseEntity;
import org.hibernate.annotations.CompositeType;
import org.hibernate.envers.AuditOverride;
import org.hibernate.envers.Audited;
import org.hibernate.envers.NotAudited;
import org.openwms.core.units.UnitConstants;
import org.openwms.core.units.api.Measurable;
import org.openwms.core.units.api.Piece;
import org.openwms.core.units.api.Weight;
import org.openwms.core.units.persistence.UnitUserType;
import org.openwms.values.Message;
import org.openwms.wms.api.TimeProvider;
import org.openwms.wms.inventory.api.AvailabilityState;
import org.openwms.wms.location.Location;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.util.Assert;

import java.io.Serializable;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.StringJoiner;
import java.util.UUID;

import static org.openwms.wms.api.TimeProvider.DATE_TIME_WITH_TIMEZONE;
import static org.openwms.wms.inventory.InventoryStringListConverter.STRING_LIST_LENGTH;

/**
 * A PackagingUnit represents a quantity of a {@link Product} packed into a single physical unit.
 *
 * @author Heiko Scherrer
 */
@Audited
@AuditOverride(forClass = ApplicationEntity.class)
@AuditOverride(forClass = BaseEntity.class)
@Entity
@Table(name = "WMS_INV_PACKAGING_UNIT")
public class PackagingUnit extends ApplicationEntity implements Serializable {

    @Transient
    private final transient TimeProvider timeProvider = ServiceLoader.load(TimeProvider.class).iterator().next();

    private static final String LOAD_UNIT_MUST_NOT_BE_NULL = "LoadUnit must not be null";
    private static final String LOCATION_MUST_NOT_BE_NULL = "Location must not be null";
    private static final String QUANTITY_MUST_NOT_BE_NULL = "Quantity must not be null";
    private static final String PRODUCT_MUST_NOT_BE_NULL = "Product must not be null";

    /** Some hint where to find the {@link PackagingUnit} within its container. */
    @Column(name = "C_PHYSICAL_POS")
    public String physicalPosition;

    /** The packaged {@link Product}. */
    @ManyToOne(fetch = FetchType.LAZY) //Define FetchType.LAZY for @ManyToOne association
    @JoinColumn(name = "C_PRODUCT_PK", referencedColumnName = "C_PK", foreignKey = @ForeignKey(name = "FK_PU_PRODUCT_PK"))
    private Product product;

    /**
     * A non-unique serial number, that might be {@literal null} in projects. Non-unique because a {@link PackagingUnit} can be split up
     * into multiples that share the same serial.
     */
    @Column(name = "C_SERIAL_NUMBER")
    private String serialNumber;

    /** The business key referring to a defined {@code Lot}. */
    @Column(name = "C_LOT_ID")
    private String lotId;

    /**
     * The expiration date of this particular {@link PackagingUnit}. The {@code Lot} may also store an expiration date that is only
     * considered when it is not explicitly overridden on the {@link PackagingUnit}.
     */
    @Column(name = "C_EXPIRES_AT", columnDefinition = "timestamp(0)")
    @DateTimeFormat(pattern = DATE_TIME_WITH_TIMEZONE)
    private ZonedDateTime expiresAt;

    /** The production date of this particular {@link PackagingUnit}. */
    @Column(name = "C_PRODUCED_AT", columnDefinition = "timestamp(0)")
    @DateTimeFormat(pattern = DATE_TIME_WITH_TIMEZONE)
    private ZonedDateTime producedAt;

    /** A {@link PackagingUnit} can have arbitrary labels assigned. */
    @Size(max = STRING_LIST_LENGTH)
    @Column(name="C_LABELS", length = STRING_LIST_LENGTH)
    @Convert(converter = InventoryStringListConverter.class)
    private List<String> labels;

    /** The current quantity of this {@link PackagingUnit}. */
    @NotNull
    @CompositeType(UnitUserType.class)
    @AttributeOverride(name = "unitType", column = @Column(name = "C_QTY_TYPE", nullable = false))
    @AttributeOverride(name = "magnitude", column = @Column(name = "C_QTY", length = UnitConstants.QUANTITY_LENGTH, nullable = false))
    private Measurable<?,?,?> quantity;

    /** Tracks all active {@link Reservation}s on this {@link PackagingUnit}. */
    @OneToMany(mappedBy = "packagingUnit", cascade = {CascadeType.ALL})
    private List<Reservation> reservations;

    /** Availability state of this {@link PackagingUnit}. */
    @NotNull
    @Column(name = "C_AV_STATE", nullable = false)
    @Enumerated(EnumType.STRING)
    private AvailabilityState availabilityState = AvailabilityState.AVAILABLE;

    /** The first-in-first-out date is used to control the allocation strategy. */
    @Column(name = "C_FIFO_DATE")
    @Temporal(TemporalType.TIMESTAMP)
    private Date fifoDate;

    /** The carrying {@link LoadUnit}. */
    @ManyToOne
    @JoinColumn(name = "C_LOAD_UNIT", foreignKey = @ForeignKey(name = "FK_LU_PK"))
    private LoadUnit loadUnit;

    /** The {@link Location} the {@link PackagingUnit} is placed on. */
    @ManyToOne
    @JoinColumn(name = "C_ACTUAL_LOCATION", foreignKey = @ForeignKey(name = "FK_PU_LOC_PK"))
    private Location actualLocation;

    /** The current weight of the {@link PackagingUnit}. */
    @CompositeType(UnitUserType.class)
    @AttributeOverride(name = "unitType", column = @Column(name = "C_WEIGHT_TYPE"))
            @AttributeOverride(name = "magnitude", column = @Column(name = "C_WEIGHT"))
    private Weight weight;

    /** The current dimension of the {@link PackagingUnit}. */
    @Embedded
    private Dimension dimension;

    /** A message placed on the {@link PackagingUnit}. */
    @Embedded
    private Message message;

    /** Is the {@code PackagingUnit} marked for deletion? */
    @Column(name = "C_ALIVE")
    private Boolean alive;

    /* ------------------- collection mapping ------------------- */
    /** Parent {@link PackagingUnit}. */
    @ManyToOne
    @JoinColumn(name = "C_PARENT", foreignKey = @ForeignKey(name = "FK_PU_PU_PARENT"))
    private PackagingUnit parent;

    /** Described in what kind of UOM the {@link PackagingUnit} is stored in. */
    @ManyToOne
    @JoinColumn(name = "C_UOM_RELATION", foreignKey = @ForeignKey(name = "FK_PU_UOMREL"))
    private UomRelation uomRelation;

    /** Child {@link PackagingUnit}s. */
    @NotAudited
    @OneToMany(mappedBy = "parent", cascade = {CascadeType.ALL})
    private Set<PackagingUnit> packagingUnits = new HashSet<>();

    /** Arbitrary detail information on this {@link PackagingUnit}. */
    @ElementCollection
    @CollectionTable(name = "WMS_INV_PACKAGING_UNIT_DETAIL",
            joinColumns = {
                    @JoinColumn(name = "C_PU_PK", referencedColumnName = "C_PK")
            },
            foreignKey = @ForeignKey(name = "FK_DETAILS_PU")
    )
    @MapKeyColumn(name = "C_KEY")
    @Column(name = "C_VALUE")
    private Map<String, String> details = new HashMap<>();

    /*~ ----------------------------- constructors ------------------- */
    /** Dear JPA... */
    protected PackagingUnit() {}

    /**
     * Create a {@link PackagingUnit} with the quantity of {@code qty} and the {@link Product} taken from the {@link LoadUnit lu}.
     *
     * @param lu The LoadUnit where this PackingUnit is stored in
     * @param qty The quantity
     */
    public PackagingUnit(LoadUnit lu, Measurable qty) {
        Assert.notNull(lu, LOAD_UNIT_MUST_NOT_BE_NULL);
        Assert.notNull(qty, QUANTITY_MUST_NOT_BE_NULL);
        Assert.notNull(lu.getProduct(), "LoadUnit.Product must not be null");
        this.quantity = qty;
        this.product = lu.getProduct();
        this.alive = true;
        assignInitialValues(lu);
    }

    /**
     * Create a {@link PackagingUnit} with the quantity of {@code qty} and the {@link Product} within the given
     * {@link LoadUnit}.
     *
     * @param lu The LoadUnit where this PackingUnit is stored in
     * @param qty The quantity
     * @param product The Product
     */
    public PackagingUnit(LoadUnit lu, Measurable qty, Product product) {
        Assert.notNull(lu, LOAD_UNIT_MUST_NOT_BE_NULL);
        Assert.notNull(qty, QUANTITY_MUST_NOT_BE_NULL);
        Assert.notNull(product, PRODUCT_MUST_NOT_BE_NULL);
        this.quantity = qty;
        this.product = product;
        this.alive = true;
        assignInitialValues(lu);
    }

    /**
     * Create a {@link PackagingUnit} with the quantity of {@code qty} and the {@link Product}.
     *
     * @param qty The quantity
     * @param product The Product
     */
    public PackagingUnit(Measurable qty, Product product) {
        Assert.notNull(qty, QUANTITY_MUST_NOT_BE_NULL);
        Assert.notNull(product, PRODUCT_MUST_NOT_BE_NULL);
        this.quantity = qty;
        this.product = product;
        this.alive = true;
    }

    /*~ ----------------------------- methods ------------------- */
    public void assignPKey() {
        setPersistentKey(UUID.randomUUID().toString());
    }

    public PackagingUnit makeSplitPU(LoadUnit loadUnit, Measurable qtyPicked, Product product) {
        var targetPU = new PackagingUnit(loadUnit, qtyPicked, product);
        targetPU.setAvailabilityState(AvailabilityState.AVAILABLE);
        targetPU.setFifoDate(timeProvider.nowAsDate());
        targetPU.setLotId(this.getLotId());
        targetPU.setSerialNumber(this.getSerialNumber());
        targetPU.setExpiresAt(this.getExpiresAt());
        targetPU.setAlive(this.getAlive());
        return targetPU;
    }

    public PackagingUnit makeSplitPU(Measurable newQuantity, Product product) {
        var targetPU = new PackagingUnit(newQuantity, product);
        targetPU.setAvailabilityState(AvailabilityState.AVAILABLE);
        targetPU.setFifoDate(timeProvider.nowAsDate());
        targetPU.setLotId(this.getLotId());
        targetPU.setSerialNumber(this.getSerialNumber());
        targetPU.setExpiresAt(this.getExpiresAt());
        targetPU.setAlive(this.getAlive());
        return targetPU;
    }

    private void assignInitialValues(LoadUnit lu) {
        this.loadUnit = lu;
        this.loadUnit.addPackagingUnit(this);
    }

    public boolean addReservation(Reservation reservation) {
        if (!hasReservations()) {
            this.reservations = new ArrayList<>(1);
        }
        return this.reservations.add(reservation);
    }

    public boolean removeReservation(Reservation reservation) {
        if (hasReservations()) {
            return this.reservations.remove(reservation);
        }
        return false;
    }

    public Measurable getQtyAvailable() {
        if (hasReservations()) {
            Measurable qtyReserved = Piece.ZERO;
            List<Measurable> all = reservations.stream().map(Reservation::getQuantityReserved).toList();
            for (Measurable measurable : all) {
                qtyReserved = qtyReserved.add(measurable);
            }
            return this.quantity.subtract(qtyReserved);
        }
        return this.quantity;
    }

    /**
     * Check whether this {@link PackagingUnit} has a quantity less than 1.
     *
     * @return {@literal true} if so, otherwise {@literal false}
     */
    public boolean isEmpty() {
        return quantity == null || quantity.isZero() || quantity.isNegative();
    }

    public boolean hasActualLocation() {
        return this.actualLocation != null;
    }

    @Transient
    @Valid
    public boolean isValid() {
        return (this.loadUnit != null && this.actualLocation == null) ||
                (this.loadUnit == null && this.actualLocation != null);
    }

    /*~ ----------------------------- accessors ------------------- */

    /**
     * Get the physicalPosition.
     *
     * @return The physicalPosition
     */
    public String getPhysicalPosition() {
        return physicalPosition;
    }

    /**
     * Set the physicalPosition.
     *
     * @param physicalPosition The physicalPosition
     */
    public void setPhysicalPosition(String physicalPosition) {
        this.physicalPosition = physicalPosition;
    }

    /**
     * Get the product.
     *
     * @return the product.
     */
    public Product getProduct() {
        return product;
    }

    /**
     * Set or override the {@link Product}.
     *
     * @param product The product to set
     */
    public void setProduct(Product product) {
        Assert.notNull(product, PRODUCT_MUST_NOT_BE_NULL);
        this.product = product;
    }

    /**
     * Get the serialNumber.
     *
     * @return The serialNumber or {@literal null}
     */
    public String getSerialNumber() {
        return serialNumber;
    }

    /**
     * Set or override the serialNumber.
     *
     * @param serialNumber The serialNumber to set
     */
    public void setSerialNumber(String serialNumber) {
        this.serialNumber = serialNumber;
    }

    /**
     * Get the persistent key of the {@code Lot}.
     *
     * @return The persistent key or {@literal null}
     */
    public String getLotId() {
        return lotId;
    }

    /**
     * Set or override the persistent key of the {@code Lot}.
     *
     * @param lotPKey The persistent key of the Lot
     */
    public void setLotId(String lotPKey) {
        this.lotId = lotPKey;
    }

    /**
     * When the {@link PackagingUnit} is going to expire.
     *
     * @return As date
     */
    public ZonedDateTime getExpiresAt() {
        return expiresAt;
    }

    /**
     * Set the expiration date of the {@link PackagingUnit}.
     *
     * @param expiresAt As date
     */
    public void setExpiresAt(ZonedDateTime expiresAt) {
        this.expiresAt = expiresAt;
    }

    /**
     * Get the date when the {@link PackagingUnit} has been produced.
     *
     * @return As date
     */
    public ZonedDateTime getProducedAt() {
        return producedAt;
    }

    /**
     * Set the production date of the {@link PackagingUnit}.
     *
     * @param producedAt As date
     */
    public void setProducedAt(ZonedDateTime producedAt) {
        this.producedAt = producedAt;
    }

    /**
     * Get the labels assigned to the {@link PackagingUnit}.
     *
     * @return As a list of strings
     */
    public List<String> getLabels() {
        return labels;
    }

    /**
     * Set the labels on the {@link PackagingUnit}.
     *
     * @param labels A list of strings to assigne
     */
    public void setLabels(List<String> labels) {
        this.labels = labels;
    }

    /**
     * Get the quantity.
     *
     * @return the quantity or {@literal null}
     */
    public Measurable getQuantity() {
        return quantity;
    }

    /**
     * Set or override the quantity.
     *
     * @param qty The quantity to set
     */
    public void setQuantity(Measurable qty) {
        Assert.notNull(qty, QUANTITY_MUST_NOT_BE_NULL);
        this.quantity = qty;
    }

    /**
     * Get the reservation.
     *
     * @return Or {@literal null} if not reserved
     */
    public List<Reservation> getReservations() {
        return reservations;
    }

    /**
     * Checks whether this {@link PackagingUnit} has reservations or not.
     *
     * @return {@literal true} if it has reservations on it otherwise {@literal false}
     */
    public boolean hasReservations() {
        return reservations != null && !reservations.isEmpty();
    }

    /**
     * Set or override reservations on this {@link PackagingUnit}.
     *
     * @param reservations The reservations to set
     */
    public void setReservations(List<Reservation> reservations) {
        this.reservations = reservations;
    }

    /**
     * Checks whether this {@link PackagingUnit} is reserved by someone with the {@code reservationId}.
     *
     * @param reservationId Compared against the reservation.reservedBy field
     * @return {@literal true if it matches}
     */
    public Optional<Reservation> getReservedBy(String reservationId) {
        if (reservationId == null || reservationId.isEmpty()) {
            throw new IllegalStateException("ReservationId is null, can't check this PackagingUnit is reserved by someone");
        }
        if (hasReservations()) {
            return reservations.stream()
                    .filter(r -> reservationId.equals(r.getReservedBy()))
                    .findFirst();
        }
        return Optional.empty();
    }

    /**
     * Checks whether this {@link PackagingUnit} is reserved by someone with the {@code reservationId}.
     *
     * @param reservationId Compared against the reservation.reservedBy field
     * @return {@literal true} if it matches
     */
    public boolean isReservedBy(String reservationId) {
        return getReservedBy(reservationId).isPresent();
    }

    /**
     * Get the availabilityState.
     *
     * @return the availabilityState
     */
    public AvailabilityState getAvailabilityState() {
        return availabilityState;
    }

    public boolean hasAvailabilityState() {
        return this.availabilityState != null;
    }

    /**
     * Set or override the availabilityState.
     *
     * @param availabilityState The availabilityState to set
     */
    public void setAvailabilityState(AvailabilityState availabilityState) {
        if (availabilityState == null) {
            return;
        }
        this.availabilityState = availabilityState;
    }

    /**
     * Get the fifoDate.
     *
     * @return the fifoDate or {@literal null}
     */
    public Date getFifoDate() {
        if (fifoDate == null) {
            return null;
        }
        return new Date(fifoDate.getTime());
    }

    /**
     * Set or override the fifoDate.
     *
     * @param fifoDate The fifoDate
     */
    public void setFifoDate(Date fifoDate) {
        this.fifoDate = fifoDate;
    }

    /**
     * Set the fifoDate if not already initialized.
     *
     * @param fifoDate The fifoDate
     */
    private void initFifoDate(Date fifoDate) {
        if (this.fifoDate == null) {
            this.fifoDate = fifoDate;
        }
    }

    /**
     * Get the {@link LoadUnit}.
     *
     * @return the loadUnit or {@literal null}
     */
    public LoadUnit getLoadUnit() {
        return loadUnit;
    }

    /**
     * Checks if the {@link PackagingUnit} has a {@link LoadUnit} assigned.
     *
     * @return {@literal true} if so
     */
    public boolean hasLoadUnit() {
        return this.loadUnit != null;
    }

    /**
     * Set or override the {@link LoadUnit} this {@link PackagingUnit} is stored in.
     *
     * @param loadUnit The LoadUnit instance
     */
    public void setLoadUnit(LoadUnit loadUnit) {
        Assert.notNull(loadUnit, LOAD_UNIT_MUST_NOT_BE_NULL);
        this.loadUnit = loadUnit;
        this.actualLocation = null; // As soon as a LoadUnit is assigned the Location is not tracked anymore
    }

    /** Set the assigned {@link LoadUnit} to {@literal null} and the {@link PackagingUnit} to the given {@code newLocation}. */
    public void unbindFromLoadUnit(Location newLocation) {
        Assert.notNull(newLocation, LOCATION_MUST_NOT_BE_NULL);
        this.loadUnit = null;
        this.actualLocation = newLocation;
    }

    /**
     * Get the actual {@link Location}.
     *
     * @return The actual Location
     */
    public Location getActualLocation() {
        return actualLocation;
    }

    /**
     * Set or override the actual {@link Location}.
     *
     * @param actualLocation The actualLocation
     */
    public void setActualLocation(Location actualLocation) {
        this.actualLocation = actualLocation;
    }

    /**
     * Get the current weight.
     *
     * @return The weight
     */
    public Weight getWeight() {
        return weight;
    }

    /**
     * Set or override the weight.
     *
     * @param weight The weight to set
     */
    public void setWeight(Weight weight) {
        this.weight = weight;
    }

    /**
     * Get the current dimension.
     *
     * @return The dimension
     */
    public Dimension getDimension() {
        return dimension;
    }

    /**
     * Set or override the dimension.
     *
     * @param dimension The dimension to set
     */
    public void setDimension(Dimension dimension) {
        this.dimension = dimension;
    }

    /**
     * Get the current {@link Message}.
     *
     * @return The message
     */
    public Message getMessage() {
        return message;
    }

    /**
     * Set or override a {@link Message}.
     *
     * @param message The message
     */
    public void setMessage(Message message) {
        this.message = message;
    }

    public Boolean getAlive() {
        return alive;
    }

    public void setAlive(Boolean alive) {
        if (alive == null || alive == Boolean.FALSE) {
            // Do not allow to turn back already removed ones
            return;
        }
        this.alive = alive;
    }

    public void kill() {
        this.alive = Boolean.FALSE;
        if (this.hasLoadUnit()) {
            this.loadUnit.removePackagingUnits(this);
            this.loadUnit = null;
        }
        if (this.hasActualLocation()) {
            this.actualLocation = null;
        }
        this.product = null; // In order to be able to delete a Product as well
    }

    public UomRelation getUomRelation() {
        return uomRelation;
    }

    public boolean hasUomRelation() {
        return this.uomRelation != null;
    }

    public void setUomRelation(UomRelation uomRelation) {
        this.uomRelation = uomRelation;
    }

    /**
     * Get the parent {@link PackagingUnit}.
     *
     * @return The parent instance or {@literal null}
     */
    public PackagingUnit getParent() {
        return parent;
    }

    /**
     * Whether the {@link PackagingUnit} has a parent or not.
     *
     * @return {@literal true} if it has a parent
     */
    public boolean hasParent() {
        return this.parent != null;
    }

    /**
     * Set the parent.
     *
     * @param parent May also be {@literal null}
     */
    public void setParent(PackagingUnit parent) {
        this.parent = parent;
    }

    /**
     * Get the child {@link PackagingUnit}s.
     *
     * @return All children or {@literal null}
     */
    public Set<PackagingUnit> getPackagingUnits() {
        return packagingUnits;
    }

    /**
     * Set the {@link PackagingUnit}.
     *
     * @param packagingUnits May also be {@literal null}
     */
    public void setPackagingUnits(Set<PackagingUnit> packagingUnits) {
        this.packagingUnits = packagingUnits;
    }

    /**
     * Get the details.
     *
     * @return As Map of Strings
     */
    public Map<String, String> getDetails() {
        return details;
    }

    public void setDetails(Map<String, String> details) {
        this.details = details;
    }

    /**
     * {@inheritDoc}
     *
     * - Not the timeProvider
     * - Not the packagingUnits
     * - Not the reservations
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof PackagingUnit)) return false;
        if (!super.equals(o)) return false;
        PackagingUnit that = (PackagingUnit) o;
        return Objects.equals(physicalPosition, that.physicalPosition) && Objects.equals(product, that.product) && Objects.equals(serialNumber, that.serialNumber) && Objects.equals(lotId, that.lotId) && Objects.equals(expiresAt, that.expiresAt) && Objects.equals(labels, that.labels) && Objects.equals(quantity, that.quantity) && availabilityState == that.availabilityState && Objects.equals(fifoDate, that.fifoDate) && Objects.equals(loadUnit, that.loadUnit) && Objects.equals(actualLocation, that.actualLocation) && Objects.equals(weight, that.weight) && Objects.equals(dimension, that.dimension) && Objects.equals(message, that.message) && Objects.equals(parent, that.parent) && Objects.equals(uomRelation, that.uomRelation);
    }

    /**
     * {@inheritDoc}
     *
     * - Not the timeProvider
     * - Not the packagingUnits
     * - Not the reservations
     */
    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), physicalPosition, product, serialNumber, lotId, expiresAt, labels, quantity, availabilityState, fifoDate, loadUnit, actualLocation, weight, dimension, message, parent, uomRelation);
    }

    /**
     * {@inheritDoc}
     *
     * - Not the timeProvider
     * - Not the packagingUnits
     * - Not the reservations
     */
    @Override
    public String toString() {
        return new StringJoiner(", ", PackagingUnit.class.getSimpleName() + "[", "]")
                .add("persistentKey='" + super.getPersistentKey() + "'")
                .add("physicalPosition='" + physicalPosition + "'")
                .add("product=" + product)
                .add("serialNumber='" + serialNumber + "'")
                .add("lotId='" + lotId + "'")
                .add("expiresAt=" + expiresAt)
                .add("labels=" + labels)
                .add("quantity=" + quantity)
                .add("availabilityState=" + availabilityState)
                .add("fifoDate=" + fifoDate)
                .add("loadUnit=" + loadUnit)
                .add("actualLocation=" + actualLocation)
                .add("weight=" + weight)
                .add("dimension=" + dimension)
                .add("message=" + message)
                .add("parent=" + parent)
                .add("uomRelation=" + uomRelation)
                .toString();
    }

    public void validate() {
        if (product != null && uomRelation != null) {
            if (!product.equals(uomRelation.getProduct())) {
                throw new ValidationException("PackagingUnit product is different to the one it references in it's uomRelation");
            }
        }
        if (product == null && uomRelation == null) {
            throw new ValidationException("Not allowed that both product and uomRelation are not set");
        }
    }

    public void completeBeforeCreation(Date now) {
        if (!this.hasAvailabilityState()) {
            this.setAvailabilityState(AvailabilityState.AVAILABLE);
        }
        this.setAlive(true);
        this.assignPKey();
        this.initFifoDate(now);
    }
}