PackagingUnitMoverImpl.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.exception.BusinessRuntimeException;
import org.ameba.exception.NotFoundException;
import org.ameba.i18n.Translator;
import org.openwms.core.units.api.Measurable;
import org.openwms.core.units.api.Piece;
import org.openwms.wms.inventory.LoadUnit;
import org.openwms.wms.inventory.LoadUnitService;
import org.openwms.wms.inventory.PackagingUnit;
import org.openwms.wms.inventory.PackagingUnitCreator;
import org.openwms.wms.inventory.PackagingUnitFinder;
import org.openwms.wms.inventory.PackagingUnitMover;
import org.openwms.wms.inventory.ProductService;
import org.openwms.wms.inventory.api.MovePURequestVO;
import org.openwms.wms.inventory.api.ProductVO;
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 java.util.function.Consumer;

import static java.lang.String.format;
import static org.openwms.wms.InventoryMessageCodes.PU_NO_PRODUCT_TO_MOVE;
import static org.openwms.wms.InventoryMessageCodes.PU_NO_PUS_ON_LOCATION;

/**
 * A PackagingUnitMoverImpl is a Spring managed transactional bean that is responsible for moving {@link PackagingUnit}s.
 *
 * @author Heiko Scherrer
 */
@Validated
@TxService
class PackagingUnitMoverImpl implements PackagingUnitMover {

    private static final Logger LOGGER = LoggerFactory.getLogger(PackagingUnitMoverImpl.class);
    private final ApplicationEventPublisher eventPublisher;
    private final Translator translator;
    private final PackagingUnitRepository repository;
    private final PackagingUnitCreator packagingUnitCreator;
    private final PackagingUnitFinder packagingUnitFinder;
    private final ProductService productService;
    private final LocationService locationService;
    private final LoadUnitService loadUnitService;

    PackagingUnitMoverImpl(ApplicationEventPublisher eventPublisher, Translator translator, PackagingUnitRepository repository,
            PackagingUnitCreator packagingUnitCreator, PackagingUnitFinder packagingUnitFinder, ProductService productService,
            LocationService locationService, LoadUnitService loadUnitService) {
        this.eventPublisher = eventPublisher;
        this.translator = translator;
        this.repository = repository;
        this.packagingUnitCreator = packagingUnitCreator;
        this.packagingUnitFinder = packagingUnitFinder;
        this.productService = productService;
        this.locationService = locationService;
        this.loadUnitService = loadUnitService;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public void moveAll(@NotEmpty String sourceTransportUnitBK, @NotEmpty String sourceLoadUnitPosition,
            @NotEmpty String targetTransportUnitBK, @NotEmpty String targetLoadUnitPosition,
            @NotNull ProductVO product) {
        LoadUnit sourceLoadUnit = getLoadUnit(sourceTransportUnitBK, sourceLoadUnitPosition);
        LoadUnit targetLoadUnit = getLoadUnit(targetTransportUnitBK, targetLoadUnitPosition);
        var pus = sourceLoadUnit.getPackagingUnits();
        move(pus,
                product.getSku(),
                pu -> {
                    sourceLoadUnit.getPackagingUnits().remove(pu);
                    targetLoadUnit.getPackagingUnits().add(pu);
                    pu.setLoadUnit(targetLoadUnit);
                    eventPublisher.publishEvent(new PackagingUnitEvent(pu, PackagingUnitEvent.TYPE.MOVED, sourceLoadUnit));
                });
        LOGGER.debug("Moved all PackagingUnits of Product [{}] to new LoadUnit [{}] ", product, targetLoadUnit);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public void moveAllToLocation(@NotEmpty String sourceTransportUnitBK, @NotEmpty String sourceLoadUnitPosition,
            @NotNull LocationVO targetLocation, @NotNull ProductVO product) {
        var sourceLoadUnit = getLoadUnit(sourceTransportUnitBK, sourceLoadUnitPosition);
        final var location = resolve(targetLocation);
        LOGGER.debug("Moving all PackagingUnits of Product [{}] to Location [{}] ", product, location);
        move(sourceLoadUnit.getPackagingUnits(),
                product.getSku(),
                pu -> {
                    sourceLoadUnit.getPackagingUnits().remove(pu);
                    pu.unbindFromLoadUnit(location);
                    eventPublisher.publishEvent(new PackagingUnitEvent(pu, PackagingUnitEvent.TYPE.MOVED, sourceLoadUnit));
                });
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public void moveQuantity(@NotBlank String sourceTransportUnitBK, @NotBlank String sourceLoadUnitPosition,
                             @NotBlank String targetTransportUnitBK, @NotBlank String targetLoadUnitPosition,
                             @NotNull Measurable quantity, @NotNull ProductVO product) {
        var sourceLoadUnit = getLoadUnit(sourceTransportUnitBK, sourceLoadUnitPosition);
        var targetLoadUnit = getLoadUnit(targetTransportUnitBK, targetLoadUnitPosition);
        move(sourceLoadUnit.getPackagingUnits(),
                product.getSku(),
                quantity,
                pu -> {
                    sourceLoadUnit.getPackagingUnits().remove(pu);
                    targetLoadUnit.getPackagingUnits().add(pu);
                    pu.setLoadUnit(targetLoadUnit);
                    eventPublisher.publishEvent(new PackagingUnitEvent(pu, PackagingUnitEvent.TYPE.MOVED, sourceLoadUnit));
                },
                pu ->
                        packagingUnitCreator.create(pu.makeSplitPU(targetLoadUnit, pu.getQuantity(), pu.getProduct()))
        );
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public void moveQuantityToLocation(@NotEmpty String sourceTransportUnitBK, @NotEmpty String sourceLoadUnitPosition,
            @NotNull LocationVO targetLocation, @NotNull Measurable quantity,
            @NotNull ProductVO product) {
        var sourceLoadUnit = getLoadUnit(sourceTransportUnitBK, sourceLoadUnitPosition);
        productService.findBySKUorThrow(product.getSku());
        var location = resolve(targetLocation);
        move(sourceLoadUnit.getPackagingUnits(),
                product.getSku(), quantity,
                pu -> {
                    sourceLoadUnit.getPackagingUnits().remove(pu);
                    pu.unbindFromLoadUnit(location);
                    eventPublisher.publishEvent(new PackagingUnitEvent(pu, PackagingUnitEvent.TYPE.MOVED, sourceLoadUnit));
                },
                pu -> {
                    var newPU = pu.makeSplitPU(quantity, pu.getProduct());
                    newPU.setActualLocation(location);
                    packagingUnitCreator.create(newPU);
                });
    }

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

    private void move(List<PackagingUnit> pus, String sku, Consumer<PackagingUnit> consumer) {
        productService.findBySKUorThrow(sku);
        var in = new ArrayList<>(pus);
        in.stream()
                .filter(pu -> pu.getProduct().getSku().equals(sku))
                .forEach(pu -> {
                    consumer.accept(pu);
                    repository.save(pu);
                });
    }

    private void move(List<PackagingUnit> pusOnSource, String sku, Measurable quantity, Consumer<PackagingUnit> onMovePU,
                      Consumer<PackagingUnit> onSplitPU) {
        productService.findBySKUorThrow(sku);
        final var result = new Collector();
        var in = new ArrayList<>(pusOnSource);
        in.stream()
                .filter(pu -> pu.getProduct().getSku().equals(sku))
                .sorted((o1, o2) -> o1.getQuantity().compareTo(o2.getQuantity()))
                .takeWhile(pu -> result.sum.compareTo(quantity) < 0)
                .forEach(result::add);
        if (result.isEmpty()) {
            LOGGER.debug("All filtered out, either not the right Product or not enough quantity");
            if (!in.isEmpty()) {

                // Split PU
                var first = in.stream()
                        .filter(pu -> pu.getProduct().getSku().equals(sku))
                        .sorted((o1, o2) -> o1.getQuantity().compareTo(o2.getQuantity()))
                        .findFirst()
                        .orElseThrow(() -> new BusinessRuntimeException(translator, PU_NO_PRODUCT_TO_MOVE, new String[]{sku}, sku));
                var oldQuantity = first.getQuantity();
                var newQuantity = oldQuantity.subtract(quantity);
                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));
                }
                first.setQuantity(newQuantity);
                onSplitPU.accept(first);
                eventPublisher.publishEvent(new PackagingUnitEvent(first, PackagingUnitEvent.TYPE.QUANTITY_CHANGED, oldQuantity));
                repository.save(first);
            } else {
                LOGGER.warn("No PackagingUnits to move");
            }
        } else if (!result.isEmpty() && result.sum.compareTo(quantity) <= 0) {

            // Not all demanded found on source location
            LOGGER.debug("Found [{}] PackagingUnits but didn't satisfy demand [{}<={}]", result.pus, result.sum, quantity);
            result.pus.forEach(onMovePU);
            repository.saveAll(result.pus);
        } else {

            // WE found PUs but they exceed the demand
            LOGGER.debug("Found [{}] PackagingUnits but exceeded demand [{}>{}]", result.pus, result.sum, quantity);

            // Move the first ones (that fit)
            var firstOnes = result.pus.subList(0, result.pus.size() - 1);
            firstOnes.forEach(onMovePU);
            repository.saveAll(firstOnes);

            // Split the last
            var lastPu = result.pus.stream().toList().getLast();
            var oldQuantity = lastPu.getQuantity();
            var movedPieces = firstOnes.stream().map(pu -> pu.getQuantity().getMagnitude().intValue()).mapToInt(Integer::valueOf).sum();

            var diff = quantity.subtract(Piece.of(movedPieces));
            if (diff.isNegative()) {
                throw new IllegalArgumentException("The difference in quantities must not be negative. Demanded is expected to be greater than collected quantity");
            }
            if (diff.compareTo(oldQuantity) > 0) {
                throw new IllegalArgumentException("It's expected to have enough PU collected to satisfy the demand of [%s], but the last PU has only [%s] left over".formatted(quantity, oldQuantity));
            }
            lastPu.setQuantity(diff);
            onSplitPU.accept(lastPu);
            lastPu.setQuantity(oldQuantity.subtract(diff));
            eventPublisher.publishEvent(new PackagingUnitEvent(lastPu, PackagingUnitEvent.TYPE.QUANTITY_CHANGED, oldQuantity));
            repository.save(lastPu);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public void moveBetweenLocations(@NotNull List<MovePURequestVO> requests) {
        for (var request : requests) {
            moveBetweenLocations(request);
        }
    }

    private void moveBetweenLocations(MovePURequestVO request) {
        final var actualLocation = resolve(request.getActualLocation());
        final var targetLocation = resolve(request.getTargetLocation());
        if (request.hasQuantity()) {

            LOGGER.debug("Move PackagingUnits with a quantity of [{}] from [{}] to [{}]", request.getQuantity(),
                    request.getActualLocation(), request.getTargetLocation());

            move(packagingUnitFinder.findOnLocation(actualLocation.getErpCode()),
                    request.getProduct().getSku(),
                    request.getQuantity(),
                    pu -> {
                        pu.setActualLocation(targetLocation);
                        eventPublisher.publishEvent(new PackagingUnitEvent(pu, PackagingUnitEvent.TYPE.MOVED, actualLocation));
                    },
                    pu -> {
                        var newPU = pu.makeSplitPU(pu.getQuantity(), pu.getProduct());
                        newPU.setActualLocation(targetLocation);
                        packagingUnitCreator.create(newPU);
                    });
        } else {

            LOGGER.debug("Move all PackagingUnits from [{}] to [{}]", actualLocation, targetLocation);
            move(packagingUnitFinder.findOnLocation(actualLocation.getErpCode()),
                    request.getProduct().getSku(),
                    pu -> {
                        pu.setActualLocation(targetLocation);
                        eventPublisher.publishEvent(new PackagingUnitEvent(pu, PackagingUnitEvent.TYPE.MOVED, actualLocation));
                    });
            LOGGER.debug("Moved all PackagingUnits of Product [{}] to Location [{}] ", request.getProduct(), request.getTargetLocation());
        }
    }

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

    /**
     * {@inheritDoc}
     */
    @Override
    @Measured
    public void moveToLoadUnit(@NotNull MovePURequestVO request) {
        if (!request.hasTargetLoadUnitPosition() || !request.hasTargetTransportUnitBK()) {
            throw new IllegalArgumentException("The target LoadUnit must be given");
        }
        if (!request.hasActualLocation()) {
            throw new IllegalArgumentException("The source Location must be given");
        }
        final var sourceLocation = resolve(request.getActualLocation());
        final var targetLoadUnit = getLoadUnit(request.getTargetTransportUnitBK(), request.getTargetLoadUnitPosition());
        final var pus = packagingUnitFinder.findOnLocation(sourceLocation.getErpCode());
        if (pus.isEmpty()) {
            LOGGER.debug("No PackagingUnits exist on Location [{}], can't move anything", sourceLocation.getErpCode());
            throw new NotFoundException(translator, PU_NO_PUS_ON_LOCATION, new String[]{sourceLocation.getErpCode()},
                    sourceLocation.getErpCode());
        }
        if (request.hasQuantity()) {

            LOGGER.debug("Move PackagingUnits with a quantity of [{}] from [{}] to [{}]", request.getQuantity(),
                    sourceLocation.getErpCode(), targetLoadUnit);
            move(pus,
                    request.getProduct().getSku(),
                    request.getQuantity(),
                    pu -> {
                        pu.setLoadUnit(targetLoadUnit);
                        eventPublisher.publishEvent(new PackagingUnitEvent(pu, PackagingUnitEvent.TYPE.MOVED, sourceLocation));
                    },
                    pu -> {
                        var newPU = pu.makeSplitPU(request.getQuantity(), pu.getProduct());
                        newPU.setLoadUnit(targetLoadUnit);
                        packagingUnitCreator.create(newPU);
                    });
        } else {

            LOGGER.debug("Move all PackagingUnits from [{}] to [{}]", sourceLocation.getErpCode(), targetLoadUnit);
            move(pus,
                    request.getProduct().getSku(),
                    pu -> {
                        pu.setLoadUnit(targetLoadUnit);
                        eventPublisher.publishEvent(new PackagingUnitEvent(pu, PackagingUnitEvent.TYPE.MOVED, sourceLocation));
                    });
            LOGGER.debug("Moved all PackagingUnits of Product [{}] to LoadUnit [{}] ", request.getProduct(), targetLoadUnit);
        }
    }

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