ForkliftService.java

package com.v1rex.liftnexus.forklift.service;

import com.v1rex.liftnexus.forklift.domain.Forklift;
import com.v1rex.liftnexus.forklift.domain.ForkliftType;
import com.v1rex.liftnexus.forklift.domain.OperationalStatus;
import com.v1rex.liftnexus.forklift.dto.ForkliftRequest;
import com.v1rex.liftnexus.forklift.dto.ForkliftResponse;
import com.v1rex.liftnexus.forklift.exception.ForkliftFleetNumberExistsException;
import com.v1rex.liftnexus.forklift.exception.ForkliftNotFoundException;
import com.v1rex.liftnexus.forklift.mapper.ForkliftMapper;
import com.v1rex.liftnexus.forklift.repository.ForkliftRepository;
import com.v1rex.liftnexus.storagebin.domain.StorageBin;
import com.v1rex.liftnexus.storagebin.service.StorageBinService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * Service layer for the Forklift bounded context.
 *
 * <p>Manages the full lifecycle of warehouse forklifts: registration, location tracking,
 * operational status updates, and assignment of transport orders during solver solution
 * persistence. Cross-domain communication follows the anti-corruption rule — all external entity
 * lookups go through the respective service interfaces, never directly through repositories.
 */
@Service
@Slf4j
@RequiredArgsConstructor
public class ForkliftService {

  private final ForkliftRepository forkliftRepository;
  private final ForkliftMapper forkliftMapper;
  private final ForkliftTypeService forkliftTypeService;
  private final StorageBinService storageBinService;

  /**
   * Registers a new forklift in the warehouse fleet.
   *
   * <p>Validates that the fleet number is unique, resolves the forklift type (archetype) and
   * optional initial storage bin from their respective domains, persists the aggregate, and returns
   * a {@link ForkliftResponse} DTO.
   *
   * @param request the inbound payload containing fleet number, type ID, and optional initial
   *     storage bin ID (must not be {@code null}, must pass validation)
   * @return a DTO representing the newly created forklift
   * @throws ForkliftFleetNumberExistsException if a forklift with the given fleet number already
   *     exists in the system
   */
  @Transactional
  public ForkliftResponse createForklift(ForkliftRequest request) {
    log.info("Provisioning new warehouse asset with fleet number: {}", request.fleetNumber());

    if (forkliftRepository.existsByFleetNumber(request.fleetNumber())) {
      throw new ForkliftFleetNumberExistsException(request.fleetNumber());
    }

    ForkliftType forkliftType = forkliftTypeService.findEntityById(request.forkliftTypeId());

    StorageBin initialBin =
        request.currentStorageBinId() != null
            ? storageBinService.findEntityById(request.currentStorageBinId())
            : null;

    Forklift forklift = forkliftMapper.toEntity(request);
    forklift.setForkliftType(forkliftType);
    forklift.setCurrentStorageBin(initialBin);

    Forklift savedForklift = forkliftRepository.save(forklift);
    log.debug("Successfully registered asset ID {}", savedForklift.getId());
    return forkliftMapper.toResponse(savedForklift);
  }

  /**
   * Retrieves a single forklift by its unique identifier.
   *
   * @param id the forklift's database identifier (must be positive and exist)
   * @return a DTO representing the forklift
   * @throws ForkliftNotFoundException if no forklift with that ID is found
   */
  @Transactional(readOnly = true)
  public ForkliftResponse findById(Long id) {
    return forkliftMapper.toResponse(findEntityById(id));
  }

  /**
   * Returns a paginated list of all forklifts in the system.
   *
   * @param pageable pagination and sorting parameters (default sort: {@code id} ascending)
   * @return a page of forklift DTOs
   */
  @Transactional(readOnly = true)
  public Page<ForkliftResponse> findAll(Pageable pageable) {
    return findAllEntities(pageable).map(forkliftMapper::toResponse);
  }

  /**
   * Searches for forklifts by minimum lifting capacity or operational status.
   *
   * <p>If {@code minCapacity} is provided, results are filtered by the forklift type's max
   * capacity.
   *
   * @param minCapacity the minimum weight capacity in kilograms (optional, {@code >= 1})
   * @param pageable pagination parameters (default size: 10, sort: fleetNumber)
   * @return a page of matching forklift DTOs
   */
  @Transactional(readOnly = true)
  public Page<ForkliftResponse> findWithCapacityGreaterThan(
      Integer minCapacity, Pageable pageable) {
    log.info("Searching assets matching minimum operational lifting capacity: {}kg", minCapacity);
    return forkliftRepository
        .findByForkliftType_MaxCapacityKgGreaterThanEqual(minCapacity, pageable)
        .map(forkliftMapper::toResponse);
  }

  /**
   * Searches for forklifts by operational status.
   *
   * @param status the operational status to filter by (e.g. {@code ACTIVE}, {@code OFFLINE})
   * @param pageable pagination parameters
   * @return a page of forklift DTOs matching the given status
   */
  @Transactional(readOnly = true)
  public Page<ForkliftResponse> findByStatus(OperationalStatus status, Pageable pageable) {
    log.info("Filtering active assets by operational status: {}", status);
    return forkliftRepository.findByStatus(status, pageable).map(forkliftMapper::toResponse);
  }

  /**
   * Moves a forklift to a new storage bin location.
   *
   * <p>This operation updates the physical location of the forklift within the warehouse. Both the
   * forklift and the target bin must exist.
   *
   * @param forkliftId the identifier of the forklift to relocate
   * @param locationId the identifier of the destination storage bin
   * @return a DTO representing the updated forklift
   * @throws ForkliftNotFoundException if no forklift with the given ID exists
   */
  @Transactional
  public ForkliftResponse updateForkliftLocation(Long forkliftId, Long locationId) {
    log.info("Moving Forklift ID {} to StorageBin ID {}", forkliftId, locationId);
    Forklift forklift = findEntityById(forkliftId);
    StorageBin newStorageBin = storageBinService.findEntityById(locationId);

    forklift.setCurrentStorageBin(newStorageBin);
    Forklift updatedForklift = forkliftRepository.save(forklift);

    log.debug("Update successful for Forklift ID {}", forkliftId);
    return forkliftMapper.toResponse(updatedForklift);
  }

  /**
   * Transitions a forklift's operational status (e.g. from {@code OFFLINE} to {@code ACTIVE}).
   *
   * @param forkliftId the identifier of the target forklift
   * @param status the new operational status
   * @return a DTO representing the updated forklift
   * @throws ForkliftNotFoundException if no forklift with the given ID exists
   */
  @Transactional
  public ForkliftResponse updateOperationalStatus(Long forkliftId, OperationalStatus status) {
    log.info("Transitioning Forklift ID {} state to: {}", forkliftId, status);
    Forklift forklift = findEntityById(forkliftId);

    forklift.setStatus(status);
    Forklift updatedForklift = forkliftRepository.save(forklift);

    return forkliftMapper.toResponse(updatedForklift);
  }

  /**
   * Internal domain-level lookup: retrieves the managed {@link Forklift} entity by its ID.
   *
   * <p>This method is exposed to other services within the same domain and to the planning service
   * for solution persistence. External consumers receive a DTO; only domain-internal callers should
   * use the entity directly.
   *
   * @param id the forklift's database identifier
   * @return the persistent Forklift entity
   * @throws ForkliftNotFoundException if no entity with that ID exists
   */
  @Transactional(readOnly = true)
  public Forklift findEntityById(Long id) {
    log.info("Fetching Forklift entity with id: {}", id);
    return forkliftRepository
        .findById(id)
        .orElseThrow(
            () -> {
              log.warn("Lookup failed: Forklift ID {} not found", id);
              return new ForkliftNotFoundException(id);
            });
  }

  /**
   * Returns a paginated list of raw {@link Forklift} entities for internal domain usage.
   *
   * @param pageable pagination and sorting parameters
   * @return a page of Forklift entities
   */
  @Transactional(readOnly = true)
  public Page<Forklift> findAllEntities(Pageable pageable) {
    log.info("Fetching all managed forklift entities and returning a page");
    return forkliftRepository.findAll(pageable);
  }

  /**
   * Returns all {@link Forklift} entities without pagination for solver consumption.
   *
   * <p><b>Performance note:</b> This method loads the entire fleet into memory and is called during
   * {@code WarehouseDispatcherService.buildCurrentState()}. For large warehouses, consider
   * paginated or selective fetching (addressed in Milestone 2).
   *
   * @return a list of all Forklift entities in the database
   */
  @Transactional(readOnly = true)
  public List<Forklift> findAllEntities() {
    log.info("Fetching all managed forklift entities without pagination");
    return forkliftRepository.findAll();
  }

  /**
   * Persists the solver's assignment results back to the database.
   *
   * <p>Called by {@code WarehouseDispatcherService.saveFinalSolution()} after the Timefold solver
   * produces a solution. This method performs a bulk ID lookup to ensure all referenced forklifts
   * exist <em>before</em> applying any assignment changes. If a forklift was deleted between solver
   * completion and persistence, the operation fails with an {@link IllegalStateException} to
   * prevent partial updates.
   *
   * <p>Each database forklift's transport order list is cleared and repopulated with the
   * solver-determined assignments.
   *
   * @param forklifts the list of solver-state forklifts containing updated order assignments
   * @throws IllegalStateException if one or more forklift IDs from the solver solution no longer
   *     exist in the database (stale data)
   */
  @Transactional
  public void updateAssignedOrders(List<Forklift> forklifts) {
    log.info("Updating assigned transport orders for Forklifts");
    List<Long> ids = forklifts.stream().map(Forklift::getId).toList();
    List<Forklift> databaseForklifts = forkliftRepository.findAllById(ids);
    if (databaseForklifts.size() != ids.size()) {
      throw new IllegalStateException("One or more forklifts not found during assignment update");
    }

    for (Forklift newForklift : forklifts) {
      Forklift databaseForklift = findEntityById(newForklift.getId());

      databaseForklift.getTransportOrders().clear();
      if (newForklift.getTransportOrders() != null) {
        databaseForklift.getTransportOrders().addAll(newForklift.getTransportOrders());
      }
    }
  }
}