PackagingUnitServiceImpl.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.impl;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import org.ameba.annotation.Measured;
import org.ameba.annotation.TxService;
import org.ameba.integration.jpa.ApplicationEntity;
import org.apache.commons.lang3.SerializationUtils;
import org.openwms.core.units.api.Measurable;
import org.openwms.core.units.api.Piece;
import org.openwms.values.Message;
import org.openwms.wms.CycleAvoidingMappingContext;
import org.openwms.wms.inventory.LoadUnit;
import org.openwms.wms.inventory.LoadUnitService;
import org.openwms.wms.inventory.PackagingUnit;
import org.openwms.wms.inventory.PackagingUnitFinder;
import org.openwms.wms.inventory.PackagingUnitMapper;
import org.openwms.wms.inventory.PackagingUnitService;
import org.openwms.wms.inventory.Product;
import org.openwms.wms.inventory.ProductService;
import org.openwms.wms.inventory.ReservationService;
import org.openwms.wms.inventory.api.AvailabilityState;
import org.openwms.wms.inventory.api.DeletePURequestVO;
import org.openwms.wms.inventory.api.PackagingUnitVO;
import org.openwms.wms.inventory.api.ReportProblemVO;
import org.openwms.wms.inventory.events.PackagingUnitEvent;
import org.openwms.wms.location.Location;
import org.openwms.wms.location.LocationPK;
import org.openwms.wms.location.LocationService;
import org.openwms.wms.location.api.LocationVO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.validation.annotation.Validated;

import java.util.ArrayList;
import java.util.List;

import static java.lang.String.format;
import static org.openwms.wms.inventory.events.PackagingUnitEvent.TYPE.UNLOCKED;

/**
 * A PackagingUnitServiceImpl.
 *
 * @author Heiko Scherrer
 */
@Validated
@TxService
class PackagingUnitServiceImpl implements PackagingUnitService {

    private static final Logger LOGGER = LoggerFactory.getLogger(PackagingUnitServiceImpl.class);
    private final ApplicationEventPublisher eventPublisher;
    private final PackagingUnitMapper packagingUnitMapper;
    private final PackagingUnitRepository repository;
    private final PackagingUnitFinder packagingUnitFinder;
    private final ReservationService reservationService;
    private final LocationService locationService;
    private final LoadUnitService loadUnitService;
    private final ProductService productService;

    PackagingUnitServiceImpl(ApplicationEventPublisher eventPublisher, PackagingUnitMapper packagingUnitMapper,
            PackagingUnitRepository repository, PackagingUnitFinder packagingUnitFinder, ReservationService reservationService,
            LocationService locationService, LoadUnitService loadUnitService, ProductService productService) {
        this.eventPublisher = eventPublisher;
        this.packagingUnitMapper = packagingUnitMapper;
        this.repository = repository;
        this.packagingUnitFinder = packagingUnitFinder;
        this.reservationService = reservationService;
        this.locationService = locationService;
        this.loadUnitService = loadUnitService;
        this.productService = productService;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public void acknowledgeReservation(@NotBlank String reservationId, @NotBlank String acknowledgeId) {
        reservationService.acknowledgeReservation(reservationId, acknowledgeId);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public @NotNull PackagingUnitVO update(@NotBlank String pKey, @NotNull PackagingUnitVO packagingUnit) {
        var existing = findByPKeyInternal(pKey);
        if (existing.hasLoadUnit()) {

            if (!packagingUnit.hasLoadUnit()) {

                // Moving from a LoadUnit to a Location
                if (!packagingUnit.hasActualLocation()) {
                    throw new IllegalArgumentException(format("Not allowed to move the PackagingUnit [%s] out of a LoadUnit to nowhere", pKey));
                }
                var sourceLoadUnit = existing.getLoadUnit();
                sourceLoadUnit.getPackagingUnits().remove(existing);
                var newLocation = packagingUnit.actualLocation.getErpCode() == null
                        ? locationService.findByBK(LocationPK.fromString(packagingUnit.actualLocation.getLocationId()))
                        : locationService.findByErpCode(packagingUnit.actualLocation.getErpCode());
                LOGGER.debug("Moving PackagingUnit [{}] from LoadUnit [{}] to Location [{}]", existing.getPersistentKey(),
                        sourceLoadUnit.getPersistentKey(), newLocation.getErpCode());
                existing.unbindFromLoadUnit(newLocation);
                eventPublisher.publishEvent(new PackagingUnitEvent(existing, PackagingUnitEvent.TYPE.MOVED, sourceLoadUnit));
            } else if (!existing.getLoadUnit().getPersistentKey().equals(packagingUnit.loadUnit.getpKey())) {

                // Moving to different LoadUnit
                var targetLoadUnit = getLoadUnit(packagingUnit.loadUnit.getTransportUnitBK(), packagingUnit.loadUnit.physicalPosition);
                var sourceLoadUnit = existing.getLoadUnit();
                LOGGER.debug("Moving PackagingUnit [{}] from LoadUnit [{}] to LoadUnit [{}]", existing.getPersistentKey(),
                        sourceLoadUnit.getPersistentKey(), targetLoadUnit.getPersistentKey());
                sourceLoadUnit.removePackagingUnits(existing);
                targetLoadUnit.addPackagingUnit(existing);
                eventPublisher.publishEvent(new PackagingUnitEvent(existing, PackagingUnitEvent.TYPE.MOVED, sourceLoadUnit));
            } else {
                LOGGER.debug("LoadUnit of PackagingUnit [{}] hasn't changed and is still [{}]", pKey, existing.getLoadUnit().getPersistentKey());
            }
        } else {

            if (!packagingUnit.hasLoadUnit()) {

                // Moving from a Location to a Location
                if (!packagingUnit.hasActualLocation()) {
                    throw new IllegalArgumentException(format("Not allowed to move the PackagingUnit [%s] from a Location to nowhere", pKey));
                }
                var newLocation = packagingUnit.actualLocation.getErpCode() == null
                        ? locationService.findByBK(LocationPK.fromString(packagingUnit.actualLocation.getLocationId()))
                        : locationService.findByErpCode(packagingUnit.actualLocation.getErpCode());
                if (newLocation.equals(existing.getActualLocation())) {
                    LOGGER.debug("PackagingUnit [{}] is on the same Location [{}]. No updated needed", pKey, newLocation);
                } else {
                    var sourceLocation = existing.getActualLocation();
                    existing.setActualLocation(newLocation);
                    eventPublisher.publishEvent(new PackagingUnitEvent(existing, PackagingUnitEvent.TYPE.MOVED, sourceLocation));
                }
            } else {

                // Move from Location to LoadUnit
                var targetLoadUnit = getLoadUnit(packagingUnit.loadUnit.getTransportUnitBK(), packagingUnit.loadUnit.getPhysicalPosition());
                var sourceLocation = existing.getActualLocation();
                LOGGER.debug("Moving PackagingUnit [{}] from Location [{}] to LoadUnit [{}] on TransportUnit [{}]/[{}]",
                        existing.getPersistentKey(),
                        sourceLocation.getErpCode(),
                        targetLoadUnit.getPersistentKey(),
                        targetLoadUnit.getTransportUnit().getTransportUnitBK(),
                        targetLoadUnit.getTransportUnit().getPersistentKey());
                targetLoadUnit.addPackagingUnit(existing);
                eventPublisher.publishEvent(new PackagingUnitEvent(existing, PackagingUnitEvent.TYPE.MOVED, sourceLocation));
            }
        }
        existing = repository.save(existing);
        return packagingUnitMapper.convert(existing, new CycleAvoidingMappingContext());
    }

    private LoadUnit getLoadUnit(String transportUnitBK, String loadUnitPosition) {
        return loadUnitService.find(transportUnitBK, loadUnitPosition);
    }

    private PackagingUnit findByPKeyInternal(String pKey) {
        return packagingUnitFinder.findByPKey(pKey);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public @NotNull PackagingUnit save(@NotNull PackagingUnit packagingUnit) {
        return repository.save(packagingUnit);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public void reportProblem(@NotBlank String transportUnitBK, @NotBlank String loadUnitPosition, @NotNull ReportProblemVO problem) {
        LOGGER.debug("Reported problem [{}] on TransportUnit [{}] and LoadUnit [{}]", problem, transportUnitBK, loadUnitPosition);
        LoadUnit loadUnit = getLoadUnit(transportUnitBK, loadUnitPosition);
        if (problem.getProduct() == null || problem.getProduct().getSku() == null || problem.getProduct().getSku().isEmpty()) {
            // Issue that the client send no product!
            // TODO [openwms]: 21.09.21 Better check this with BV 
            throw new IllegalArgumentException("Reported product must not be null");
        }
        List<PackagingUnit> packagingUnits = loadUnit.getPackagingUnits().stream()
                .filter(pu -> pu.getProduct().getSku().equals(problem.getProduct().getSku()))
                .sorted((o1, o2) -> o1.getQuantity().compareTo(o2.getQuantity()))
                .toList();

        Measurable quantity = null;
        List<PackagingUnit> selected = new ArrayList<>();
        for (int i = 0; i < packagingUnits.size(); i++) {
            PackagingUnit pu = packagingUnits.get(i);
            if (quantity == null) {
                if (pu.getQuantity().compareTo(problem.getQuantity()) <= 0 || (i+1 == packagingUnits.size())) {
                    // Either the quantity matches or we don't have any PU left then grab that one
                    quantity = pu.getQuantity();
                    selected.add(pu);
                    LOGGER.debug("The PU quantity fits the problem quantity, new collected quantity is [{}]", quantity);
                } else {
                    LOGGER.debug("Continue, PU quantity is too big");
                }
            } else if (quantity.compareTo(problem.getQuantity()) < 0) {
                selected.add(pu);
                quantity = quantity.add(pu.getQuantity());
                LOGGER.debug("Add PU to collected, collected quantity is [{}]", quantity);
            } else {
                LOGGER.debug("Collected enough [{}] and break", quantity);
                break;
            }
        }
        LOGGER.debug("Collected [{}] PackagingUnits to lock", selected.size());
        List<PackagingUnit> updated = selected.stream().map(pu -> {
            pu.setAvailabilityState(AvailabilityState.LOCKED);
            pu.setMessage(new Message(problem.getMessageNo(), "" + problem.getMessageNo()));
            LOGGER.info("Locked PackagingUnit [{}] with messageNo [{}]", pu.getPersistentKey(), problem.getMessageNo());
            return repository.saveAndFlush(pu); // FIXME [openwms]: 21.09.21 Is this required to flush here?
        }).toList();
        updated = repository.saveAll(updated);
        updated.forEach(pu -> eventPublisher.publishEvent(new PackagingUnitEvent(pu, PackagingUnitEvent.TYPE.LOCKED)));
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public void release(@NotBlank String pKey) {
        var lockedPU = findByPKeyInternal(pKey);
        if (lockedPU.getAvailabilityState() != AvailabilityState.AVAILABLE) {
            lockedPU.setAvailabilityState(AvailabilityState.AVAILABLE);
            eventPublisher.publishEvent(new PackagingUnitEvent(lockedPU, UNLOCKED));
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public void deleteAll(@NotEmpty List<PackagingUnit> packagingUnits) {
        LOGGER.debug("Deleting PackagingUnits [{}]", packagingUnits.stream().map(ApplicationEntity::getPersistentKey).toList());
        packagingUnits.forEach(this::deleteInternal);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public void deleteOnLocation(@NotNull DeletePURequestVO request) {
        LOGGER.debug("Delete PackagingUnits on Location. Request: [{}]", request);
        deleteOnLocationInternal(request);
    }

    private void deleteOnLocationInternal(DeletePURequestVO request) {
        if (request.hasQuantity()) {

            LOGGER.debug("Delete PackagingUnits with a quantity of [{}] from [{}]", request.getQuantity(), request.getLocation());
            productService.findBySKUorThrow(request.getProduct().getSku());
            delete(packagingUnitFinder.findOnLocation(request.getLocation().getErpCode()), request.getProduct().getSku(), request.getQuantity());
        } else {

            var location = resolve(request.getLocation());
            LOGGER.debug("Delete all PackagingUnits from [{}]", location);
            packagingUnitFinder.findOnLocation(location.getErpCode()).forEach(this::deleteInternal);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public void deleteByPKey(@NotBlank String pKey) {
        deleteInternal(findByPKeyInternal(pKey));
    }

    private Location resolve(LocationVO location) {
        if (location.hasErpCode()) {
            return locationService.findByErpCode(location.getErpCode());
        } else if (location.hasLocationId()) {
            return locationService.findByBK(LocationPK.fromString(location.getLocationId()));
        } else {
            throw new IllegalArgumentException("Either the ERP code or the Location ID must be given");
        }
    }

    class Collector {
        Measurable sum = Piece.ZERO;
        List<PackagingUnit> pus = new ArrayList<>();

        void add(PackagingUnit pu) {
            this.pus.add(pu);
            Measurable sz = sum.add(pu.getQuantity());
            sum = sz;
        }

        boolean isEmpty() {
            return this.pus.isEmpty();
        }
    }

    void delete(List<PackagingUnit> pus, String sku, Measurable quantity) {
        if (pus.isEmpty()) {
            LOGGER.warn("No PackagingUnits to delete");
            return;
        }
        final var result = new Collector();
        var in = new ArrayList<>(pus);
        in.stream()
                .filter(pu -> pu.getProduct() != null && pu.getProduct().getSku().equals(sku))
                .sorted((o1, o2) -> o1.getQuantity().compareTo(o2.getQuantity()))
                .forEach(pu -> {
                    if (result.sum.add(pu.getQuantity()).compareTo(quantity) <= 0) {
                        result.add(pu);
                    }
                });
        if (!result.isEmpty()) {
            LOGGER.debug("Collected PackagingUnits to delete [{}]", result.pus);
            result.pus.forEach(this::deleteInternal);
        }

        // Check if more need to be deleted
        if (result.sum.compareTo(quantity) < 0) {
            LOGGER.debug("Need to split PU to hit the demanded quantity");

            var demandedQty = quantity.subtract(result.sum);
            // Split PU
            var all = in.stream()
                    .filter(pu -> pu.getProduct() != null && pu.getProduct().getSku().equals(sku))
                    .filter(pu -> !result.pus.contains(pu))
                    .sorted((o1, o2) -> o1.getQuantity().compareTo(o2.getQuantity()))
                    .toList();
            for (var packagingUnit : all) {
                var diff = packagingUnit.getQuantity().compareTo(demandedQty);
                if (diff > 0) {
                    var oldQuantity = packagingUnit.getQuantity();
                    var newQuantity = oldQuantity.subtract(demandedQty);
                    LOGGER.debug("newQuantity [{}]", newQuantity);
                    if (newQuantity.isZero() || newQuantity.isNegative()) {
                        throw new IllegalStateException(format("Expected to have enough on PackagingUnit [%s] but was only [%s]", quantity,
                                oldQuantity));
                    }
                    packagingUnit.setQuantity(newQuantity);
                    eventPublisher.publishEvent(new PackagingUnitEvent(packagingUnit, PackagingUnitEvent.TYPE.QUANTITY_CHANGED, oldQuantity));
                    repository.save(packagingUnit);
                    break;
                } else if (diff == 0) {
                    deleteInternal(packagingUnit);
                    break;
                } else {
                    demandedQty = demandedQty.subtract(packagingUnit.getQuantity());
                    deleteInternal(packagingUnit);
                }
            }
        }
    }

    private void deleteInternal(PackagingUnit packagingUnit) {
        var actualLocation = packagingUnit.getActualLocation();
        var loadUnit = packagingUnit.getLoadUnit();
        var currentProduct = packagingUnit.getProduct();
        packagingUnit.kill(); // remove all references

        var puClone = SerializationUtils.clone(packagingUnit); // deep copy the origin instance to pass also Product data over the wire
        puClone.setProduct(new Product(currentProduct.getSku(), currentProduct.getBaseUnit()));

        repository.save(packagingUnit);
        if (actualLocation != null) {
            eventPublisher.publishEvent(new PackagingUnitEvent(puClone, PackagingUnitEvent.TYPE.DELETED, actualLocation));
        } else if (loadUnit != null) {
            eventPublisher.publishEvent(new PackagingUnitEvent(puClone, PackagingUnitEvent.TYPE.DELETED, loadUnit));
        } else {
            throw new IllegalStateException("PackagingUnit must either be on a Location or in a LoadUnit");
        }
    }
}