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