MovementServiceImpl.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.movements.impl;

import jakarta.validation.Valid;
import jakarta.validation.Validator;
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.exception.ServiceLayerException;
import org.ameba.i18n.Translator;
import org.openwms.common.location.LocationPK;
import org.openwms.common.location.api.LocationApi;
import org.openwms.common.location.api.LocationGroupApi;
import org.openwms.common.location.api.LocationGroupVO;
import org.openwms.common.location.api.LocationVO;
import org.openwms.common.transport.Barcode;
import org.openwms.common.transport.api.TransportUnitApi;
import org.openwms.wms.movements.MovementService;
import org.openwms.wms.movements.api.MovementState;
import org.openwms.wms.movements.api.MovementType;
import org.openwms.wms.movements.api.MovementVO;
import org.openwms.wms.movements.api.ValidationGroups;
import org.openwms.wms.movements.spi.DefaultMovementState;
import org.openwms.wms.movements.spi.MovementStateResolver;
import org.openwms.wms.movements.spi.MovementTypeResolver;
import org.openwms.wms.movements.spi.Validators;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.plugin.core.PluginRegistry;
import org.springframework.validation.annotation.Validated;

import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

import static java.lang.String.format;
import static org.ameba.system.ValidationUtil.validate;
import static org.openwms.wms.movements.MovementsMessages.LOCATION_NOT_FOUND_BY_ERP_CODE;
import static org.openwms.wms.movements.MovementsMessages.LOCATION_NOT_FOUND_BY_ID;
import static org.openwms.wms.movements.MovementsMessages.MOVEMENT_COMPLETED_NOT_MOVED;
import static org.openwms.wms.movements.MovementsMessages.MOVEMENT_NOT_FOUND;

/**
 * A MovementServiceImpl is a Spring managed transaction service that deals with {@link Movement}s.
 *
 * @author Heiko Scherrer
 */
@Validated
@TxService
@RefreshScope
class MovementServiceImpl implements MovementService {

    private static final Logger LOGGER = LoggerFactory.getLogger(MovementServiceImpl.class);
    private final ApplicationEventPublisher eventPublisher;
    private final MovementMapper mapper;
    private final Validator validator;
    private final Translator translator;
    private final MovementStateResolver movementStateResolver;
    private final MovementRepository repository;
    private final MovementTypeResolver movementTypeResolver;
    private final PluginRegistry<MovementHandler, MovementType> handlers;
    private final Validators validators;
    private final TransportUnitApi transportUnitApi;
    private final LocationApi locationApi;
    private final LocationGroupApi locationGroupApi;

    MovementServiceImpl(ApplicationEventPublisher eventPublisher, MovementMapper mapper, Validator validator, Translator translator,
                        MovementStateResolver movementStateResolver, MovementRepository repository,
                        @Autowired(required = false) MovementTypeResolver movementTypeResolver,
                        PluginRegistry<MovementHandler, MovementType> handlers,
                        Validators validators, TransportUnitApi transportUnitApi, LocationApi locationApi, LocationGroupApi locationGroupApi) {
        this.eventPublisher = eventPublisher;
        this.mapper = mapper;
        this.validator = validator;
        this.translator = translator;
        this.movementStateResolver = movementStateResolver;
        this.repository = repository;
        this.movementTypeResolver = movementTypeResolver;
        this.handlers = handlers;
        this.validators = validators;
        this.transportUnitApi = transportUnitApi;
        this.locationApi = locationApi;
        this.locationGroupApi = locationGroupApi;
    }

    /**
     * {@inheritDoc}
     */
    @Measured
    @Validated(ValidationGroups.Movement.Create.class)
    @Override
    public @NotNull MovementVO create(
            @NotBlank(groups = ValidationGroups.Movement.Create.class) String bk,
            @NotNull(groups = ValidationGroups.Movement.Create.class) @Valid MovementVO vo) {
        LOGGER.debug("Create a Movement for [{}] with data [{}]", bk, vo);
        validateAndResolveType(vo);
        var movementHandler = resolveHandler(vo.getType());
        resolveTransportUnit(bk);
        var sourceLocation = resolveLocation(vo.getSourceLocation());
        var movement = mapper.convertTo(vo);
        try {
            resolveLocation(vo.getTarget());
        } catch ( NotFoundException nfe) {
            LOGGER.debug("The Movement has no valid target [{}] set, trying to resolve it later", vo.getTarget());
        }
        movement.setInitiatorOrDefault(movement.getInitiator(), "n/a");
        movement.setSourceLocation(sourceLocation.getErpCode());
        movement.setSourceLocationGroupName(sourceLocation.getLocationGroupName());
        movement.setTransportUnitBk(Barcode.of(bk));
        movement.setState(movementStateResolver.getNewState());
        validate(validator, movement, ValidationGroups.Movement.Create.class);
        var result = movementHandler.create(movement);
        return convert(result);
    }

    private MovementHandler resolveHandler(MovementType type) {
        var movementHandler = handlers.getPluginFor(type);
        if (movementHandler.isEmpty()) {
            throw new IllegalArgumentException(format("No handler registered for MovementType [%s]", type));
        }
        return movementHandler.get();
    }

    private void resolveTransportUnit(String bk) {
        try {
            transportUnitApi.findTransportUnit(bk);
        } catch (Exception ex) {
            throw new ServiceLayerException(ex.getMessage(), ex);
        }
    }

    private LocationVO resolveLocation(String locationIdentifier) {
        Optional<LocationVO> optLocation;
        if (LocationPK.isValid(locationIdentifier)) {
            try {
                optLocation = locationApi.findById(locationIdentifier);
            } catch (Exception ex) {
                // Any technical reasons
                throw new ServiceLayerException(ex.getMessage(), ex);
            }
            if (optLocation.isEmpty()) {
                throw new NotFoundException(translator, LOCATION_NOT_FOUND_BY_ID, new String[]{locationIdentifier}, locationIdentifier);
            }
        } else {
            try {
                optLocation = locationApi.findByErpCode(locationIdentifier);
            } catch (Exception ex) {
                // Any technical reasons
                throw new ServiceLayerException(ex.getMessage(), ex);
            }
            if (optLocation.isEmpty()) {
                throw new NotFoundException(translator, LOCATION_NOT_FOUND_BY_ERP_CODE, new String[]{locationIdentifier}, locationIdentifier);
            }
        }
        return optLocation.get();
    }

    private void validateAndResolveType(MovementVO vo) {
        if (vo.getType() == null) {
            if (!vo.hasTarget()) {
                throw new IllegalArgumentException("Can't automatically resolve a MovementType because no target is set");
            }
            if (movementTypeResolver == null) {
                throw new IllegalStateException("No type is set and needs to be resolved but no MovementTypeResolver is configured");
            }
            vo.setType(movementTypeResolver.resolve(vo.getTransportUnitBk(), vo.getTarget())
                    .orElseThrow(() -> new IllegalArgumentException(format("Can't resolve MovementType for TransportUnit [%s] from target [%s]",
                            vo.getTransportUnitBk(), vo.getTarget()))));
        }
    }

    /**
     * {@inheritDoc}
     */
    @Measured
    @Override
    public List<MovementVO> findFor(@NotNull MovementState state, @NotBlank String source, @NotEmpty MovementType... types) {
        var sources = locationGroupApi.findByName(source)
                .map(lg -> lg.streamLocationGroups().map(LocationGroupVO::getName).toList())
                .orElseGet(() -> Collections.singletonList(source));
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug("Search for Movements of types [{}] in state [{}] and in source [{}]", types, state, sources);
        }
        return Arrays.stream(types)
                .parallel()
                .map(t -> resolveHandler(t).findInStateAndSource(state, sources))
                .reduce(new ArrayList<>(), (a, b) -> {
                    a.addAll(b);
                    return a;
                }).stream().map(this::convert).toList();
    }

    /**
     * {@inheritDoc}
     */
    @Measured
    @Override
    public List<String> getPriorityList() {
        return Arrays.stream(PriorityLevel.values())
                .filter(Objects::nonNull)
                .map(Enum::name)
                .toList();
    }

    private Movement findInternal(String pKey) {
        return repository.findBypKey(pKey)
                .orElseThrow(() -> new NotFoundException(translator, MOVEMENT_NOT_FOUND, new String[]{pKey}, pKey));
    }

    /**
     * {@inheritDoc}
     */
    @Validated(ValidationGroups.Movement.Move.class)
    @Measured
    @Override
    public @NotNull MovementVO move(@NotBlank String pKey, @Valid @NotNull MovementVO vo) {
        var movement = findInternal(pKey);
        movement = validators.onMove(movement, vo.getSourceLocation(), mapper.convertTo(vo));
        if (movement.getState() == movementStateResolver.getCompletedState()) {
            throw new BusinessRuntimeException(translator, MOVEMENT_COMPLETED_NOT_MOVED, new String[]{pKey}, pKey);
        }
        movement.setState(DefaultMovementState.valueOf(vo.getState()));
        movement.initStartDate(ZonedDateTime.now());
        var sourceLocation = resolveLocation(vo.getSourceLocation());
        if (vo.hasTransportUnitBK()) {
            movement.setTransportUnitBk(Barcode.of(vo.getTransportUnitBk()));
        }
        transportUnitApi.moveTU(movement.getTransportUnitBk().getValue(), sourceLocation.getLocationId());
        var previousLocation = movement.getSourceLocation();
        movement.setSourceLocation(sourceLocation.getErpCode());
        movement.setSourceLocationGroupName(sourceLocation.getLocationGroupName());
        LOGGER.debug("Moving Movement [{}]", movement);
        movement = repository.save(movement);
        eventPublisher.publishEvent(new MovementEvent(movement, MovementEvent.Type.MOVED, previousLocation));
        return convert(movement);
    }

    /**
     * {@inheritDoc}
     */
    @Validated(ValidationGroups.Movement.Complete.class)
    @Measured
    @Override
    public @NotNull MovementVO complete(@NotBlank String pKey, @Valid @NotNull MovementVO vo) {
        LOGGER.debug("Got request to complete Movement with pKey [{}], [{}]", pKey, vo);
        var movement = findInternal(pKey);
        movement = validators.onMove(movement, vo.getTarget(), mapper.convertTo(vo));
        if (movement.getState().ordinal() < DefaultMovementState.DONE.ordinal()) {
            var location = resolveLocation(vo.getTarget());
            transportUnitApi.moveTU(vo.hasTransportUnitBK()
                    ? vo.getTransportUnitBk()
                    : movement.getTransportUnitBk().getValue(), location.getLocationId());
            movement.setState(movementStateResolver.getCompletedState());
            movement.setEndDate(ZonedDateTime.now());
            var previousLocation = location.getErpCode();
            movement.setTargetLocation(vo.getTarget());
            movement.setTargetLocationGroup(vo.getTarget());
            movement = repository.save(movement);
            eventPublisher.publishEvent(new MovementEvent(movement, MovementEvent.Type.COMPLETED, previousLocation));
        } else {
            LOGGER.info("Movement [{}] is already in state [{}] and cannot be completed", pKey, movement.getState());
        }
        return convert(movement);
    }

    /**
     * {@inheritDoc}
     */
    @Measured
    @Override
    public @NotNull MovementVO cancel(@NotBlank String pKey) {
        LOGGER.debug("Got request to cancel Movement with pKey [{}]", pKey);
        var movement = findInternal(pKey);
        if (movement.getState().ordinal() < DefaultMovementState.CANCELLED.ordinal()) {
            movement.setState(DefaultMovementState.CANCELLED);
            movement.setEndDate(ZonedDateTime.now());
            movement = repository.save(movement);
            eventPublisher.publishEvent(new MovementEvent(movement, MovementEvent.Type.CANCELLED));
        } else {
            LOGGER.info("Movement [{}] is already in state [{}] and cannot be cancelled", pKey, movement.getState());
        }
        return convert(movement);
    }

    /**
     * {@inheritDoc}
     */
    @Measured
    @Override
    public @NotNull List<MovementVO> findAll() {
        var all = repository.findAll();
        if (all.isEmpty()) {
            return Collections.emptyList();
        }
        return all.stream().map(this::convert).toList();
    }

    /**
     * {@inheritDoc}
     */
    @Measured
    @Override
    public List<MovementVO> findForTuAndTypesAndStates(@NotBlank String barcode, @NotEmpty List<MovementType> types, @NotEmpty List<String> states) {
        var all = repository.findByTransportUnitBkAndTypeInAndStateIn(
                Barcode.of(barcode),
                types,
                states.stream().map(DefaultMovementState::valueOf).toList()
        );
        if (all.isEmpty()) {
            LOGGER.debug("No Movements for TU [{}] in states [{}]", barcode, states);
            return Collections.emptyList();
        }
        LOGGER.debug("Movements for TU [{}] in states [{}] exist", barcode, states);
        return all.stream().map(this::convert).toList();
    }

    private MovementVO convert(Movement eo) {
        var vo = mapper.convertToVO(eo);
        if (eo.getTargetLocation() != null && !eo.getTargetLocation().isEmpty()) {
            vo.setTarget(eo.getTargetLocation());
        }
        return vo;
    }
}