LoadUnit.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.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import org.ameba.integration.jpa.ApplicationEntity;
import org.ameba.integration.jpa.BaseEntity;
import org.hibernate.envers.AuditOverride;
import org.hibernate.envers.Audited;
import org.hibernate.envers.NotAudited;
import org.openwms.wms.transport.TransportUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

import static jakarta.persistence.CascadeType.MERGE;
import static jakarta.persistence.CascadeType.PERSIST;
import static java.lang.String.format;
import static java.util.Arrays.asList;

/**
 * A LoadUnit is used to divide a {@code TransportUnit} into multiple physical areas. It always requires a {@code TransportUnit} to be moved
 * around, but it may contain {@code PackagingUnits} of arbitrary {@code Product} types or it may dedicated to one particular {@code Product}
 * type only.
 *
 * @author Heiko Scherrer
 */
@Audited
@AuditOverride(forClass = ApplicationEntity.class)
@AuditOverride(forClass = BaseEntity.class)
@Entity
@Table(
        name = "WMS_INV_LOAD_UNIT",
        uniqueConstraints = {
                @UniqueConstraint(name = "UC_LU_TUPK_POS", columnNames = {"C_TRANSPORT_UNIT_PK", "C_PHYSICAL_POS"}),
                @UniqueConstraint(name = "UC_LU_LABEL", columnNames = {"C_LABEL"})
        }
)
public class LoadUnit extends ApplicationEntity implements Serializable {

    private static final Logger LOGGER = LoggerFactory.getLogger(LoadUnit.class);

    /** The {@code TransportUnit} where this {@link LoadUnit} belongs to. */
    @NotNull
    @NotAudited
    @ManyToOne
    @JoinColumn(name = "C_TRANSPORT_UNIT_PK", foreignKey = @ForeignKey(name = "FK_LU_TU_PK"), nullable = false)
    private TransportUnit transportUnit;

    /** Where this {@link LoadUnit} is located on the {@code TransportUnit}. */
    @NotEmpty
    @Column(name = "C_PHYSICAL_POS", nullable = false)
    private String physicalPosition;

    /** An identifying label of the {@link LoadUnit}. */
    @Column(name = "C_LABEL")
    private String label;

    /** The {@link LoadUnitType} the {@link LoadUnit} is of. */
    @NotNull
    @NotAudited
    @ManyToOne
    @JoinColumn(name = "C_LOAD_UNIT_TYPE", referencedColumnName = "C_TYPE", foreignKey = @ForeignKey(name = "FK_LUT_TYPE"), nullable = false)
    private LoadUnitType type;

    /** Locked for allocation. */
    @Column(name = "C_LOCKED")
    private boolean locked = false;

    /** Whether it is allowed to store different {@code Products} in this LoadUnit. */
    @Column(name = "C_MIXED_PRODUCTS")
    private boolean mixedProducts = true;

    /** The Product that is carried within the LoadUnit. */
    @ManyToOne(fetch = FetchType.LAZY) //Define FetchType.LAZY for @ManyToOne association
    @JoinColumn(name = "C_PRODUCT_PK", referencedColumnName = "C_PK", foreignKey = @ForeignKey(name = "FK_LU_PRODUCT_PK"))
    private Product product;

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

    /** All {@link PackagingUnit}s that belong to this {@link LoadUnit}. */
    @OneToMany(mappedBy = "loadUnit", cascade = {PERSIST, MERGE})
    private List<PackagingUnit> packagingUnits = new ArrayList<>();

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

    /**
     * Create a new LoadUnit.
     *
     * @param transportUnit The {@code TransportUnit} where this LoadUnit stands on.
     * @param physicalPosition The physical position within the {@code TransportUnit} where this LoadUnit stands on
     * @param type The LoadUnitType to assign the LoadUnit to
     */
    public LoadUnit(TransportUnit transportUnit, String physicalPosition, LoadUnitType type) {
        this.transportUnit = transportUnit;
        this.physicalPosition = physicalPosition;
        this.type = type;
    }

    /**
     * Get the transportUnit.
     *
     * @return the transportUnit.
     */
    public TransportUnit getTransportUnit() {
        return transportUnit;
    }

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

    public String getLabel() {
        return label;
    }

    /**
     * Get the locked.
     *
     * @return the locked.
     */
    public boolean isLocked() {
        return locked;
    }

    /**
     * Set the locked.
     *
     * @param locked The locked to set.
     */
    public void setLocked(boolean locked) {
        this.locked = locked;
    }

    public boolean isMixedProducts() {
        return mixedProducts;
    }

    public void setMixedProducts(boolean mixedProducts) {
        this.mixedProducts = mixedProducts;
    }

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

    /**
     * Assign a Product to this LoadUnit.
     *
     * @param p The product to assign.
     */
    public void assignProduct(Product p) {
        if (p == null) {
            throw new IllegalArgumentException("Calling assignProduct with null not allowed, therefore call unassignProduct");
        }
        if (!mixedProducts) {
            Optional<PackagingUnit> first = getPackagingUnits().stream().findFirst();
            if (first.isPresent() && !first.get().getProduct().getSku().equals(p.getSku())) {
                throw new IllegalArgumentException(format(
                        "Not allowed to change the Product of this LoadUnit, because no mixed Products allowed, currently this LoadUnit contains [%s]",
                        first.get().getProduct().getSku()
                ));
            }
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("Mixed products not allowed but no PackagingUnit assigned so far, assign this LoadUnit to Product [{}]", p.getSku());
            }
        }
        this.product = p;
    }

    /**
     * Unassign the product from this LoadUnit - set it to {@literal null}.
     */
    public void unassignProduct() {
        product = null;
    }

    public LoadUnitType getType() {
        return type;
    }

    public Dimension getDimension() {
        return dimension;
    }

    /**
     * Get the packagingUnits.
     *
     * @return the packagingUnits.
     */
    public List<PackagingUnit> getPackagingUnits() {
        return initializeAndGet();
    }

    /**
     * Check whether this {@link LoadUnit} has {@link PackagingUnit}s.
     *
     * @return {@literal true} if so
     */
    public boolean hasPackagingUnits() {
        return packagingUnits != null && !packagingUnits.isEmpty();
    }

    // This method is not an accessor and not woven by Hibernate in order to avoid performance issues!
    private List<PackagingUnit> initializeAndGet() {
        if (packagingUnits == null) {
            packagingUnits = new ArrayList<>();
        }
        return packagingUnits;
    }

    /**
     * Add one {@link PackagingUnit} to this LoadUnit.
     *
     * @param packagingUnit {@link PackagingUnit} to add
     */
    public void addPackagingUnit(PackagingUnit packagingUnit) {
        if (packagingUnit == null) {
            return;
        }
        if (!mixedProducts && product != null && !product.getSku().equals(packagingUnit.getProduct().getSku())) {
            throw new IllegalArgumentException(format(
                    "This LoadUnit is reserved for Product [%s] only and mixed Products aren't allowed. PackagingUnit to add is of Product type [%s]",
                    product.getSku(),
                    packagingUnit.getProduct().getSku()
            ));
        }
        packagingUnit.setLoadUnit(this);
        initializeAndGet().add(packagingUnit);
    }

    /**
     * Remove one or more {@link PackagingUnit}s from this LoadUnit.
     *
     * @param pUnits {@link PackagingUnit}s to remove
     */
    public void removePackagingUnits(PackagingUnit... pUnits) {
        if (pUnits != null && pUnits.length != 0) {
            asList(pUnits).forEach(getPackagingUnits()::remove);
        }
    }

    /**
     * {@inheritDoc}
     * <p/>
     * Return a combination of the barcode and the physicalPosition.
     */
    @Override
    public String toString() {
        if (transportUnit == null) {
            return "n/a / " + physicalPosition;
        }
        return transportUnit.getTransportUnitBK() + " / " + physicalPosition;
    }

    /**
     * {@inheritDoc}
     * <p/>
     * Use the TransportUnit and the Position only
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        LoadUnit loadUnit = (LoadUnit) o;
        return Objects.equals(transportUnit, loadUnit.transportUnit) &&
                Objects.equals(physicalPosition, loadUnit.physicalPosition);
    }

    /**
     * {@inheritDoc}
     * <p/>
     * Use the TransportUnit and the Position only
     */
    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), transportUnit, physicalPosition);
    }
}