fix: decouple manual tidy from vehicle assignment
This commit is contained in:
@@ -74,11 +74,6 @@ public interface WmsAlgorithmMapper {
|
||||
|
||||
int countActiveManualTidy(@Param("warehouseId") String warehouseId);
|
||||
|
||||
List<String> selectActiveManualTidyVehicleIds(@Param("warehouseId") String warehouseId);
|
||||
|
||||
int assignTidyWorkflowVehicle(@Param("workflowId") String workflowId,
|
||||
@Param("vehicleId") String vehicleId);
|
||||
|
||||
List<InboundTaskItem> selectInboundTaskItems(@Param("jobId") String jobId);
|
||||
|
||||
int claimInboundSpace(@Param("reservationId") String reservationId,
|
||||
|
||||
@@ -8,12 +8,11 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Component
|
||||
public class ManualTidyScheduler {
|
||||
@@ -44,6 +43,7 @@ public class ManualTidyScheduler {
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${fortucky.algorithm.tidy-reconcile-ms:5000}")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void reconcileAndFill() {
|
||||
if (properties.getMode() != com.ruoyi.algorithm.integration.SchedulingMode.ACTIVE
|
||||
|| !properties.isDatabaseWritesEnabled()) return;
|
||||
@@ -52,26 +52,21 @@ public class ManualTidyScheduler {
|
||||
if (mode.effectiveState() != TidyEffectiveState.TIDY_ACTIVE || !mode.requestedEnabled()) return;
|
||||
|
||||
List<VehicleSnapshot> vehicles = vehicleClient.fetch();
|
||||
Set<String> assigned = new HashSet<>(mapper.selectActiveManualTidyVehicleIds(warehouseId));
|
||||
TidyVehicleCapacity.CapacityResult result = capacity.calculate(
|
||||
vehicles, mapper.countActiveManualTidy(warehouseId), assigned);
|
||||
vehicles, mapper.countActiveManualTidy(warehouseId),
|
||||
properties.getTidyManualMaxConcurrency());
|
||||
if (result.newSlots() == 0) return;
|
||||
|
||||
Iterator<VehicleSnapshot> vehicleIterator = vehicles.stream()
|
||||
.filter(VehicleSnapshot::availableForManualTidy)
|
||||
.filter(vehicle -> !assigned.contains(vehicle.vehicleId()))
|
||||
.iterator();
|
||||
Iterator<TidyCandidate> candidateIterator = facade.planTidy(warehouseId, Instant.now())
|
||||
.candidates().iterator();
|
||||
int remaining = result.newSlots();
|
||||
while (remaining-- > 0 && vehicleIterator.hasNext() && candidateIterator.hasNext()) {
|
||||
VehicleSnapshot vehicle = vehicleIterator.next();
|
||||
while (remaining-- > 0 && candidateIterator.hasNext()) {
|
||||
TidyCandidate candidate = candidateIterator.next();
|
||||
try {
|
||||
var workflowId = workflows.start(TidyOrigin.MANUAL_TIDY, warehouseId, candidate);
|
||||
mapper.assignTidyWorkflowVehicle(workflowId.toString(), vehicle.vehicleId());
|
||||
workflows.start(TidyOrigin.MANUAL_TIDY, warehouseId, candidate);
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Unable to start manual tidy workflow for vehicle {}", vehicle.vehicleId(), ex);
|
||||
log.warn("Unable to start manual tidy workflow for candidate {}", candidate.type(), ex);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
package com.ruoyi.algorithm.wms.tidy;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public final class TidyVehicleCapacity {
|
||||
public CapacityResult calculate(List<VehicleSnapshot> vehicles, int activeManualWorkflows,
|
||||
Set<String> assignedManualVehicleIds) {
|
||||
int maxConcurrency) {
|
||||
int effective = (int) vehicles.stream()
|
||||
.filter(VehicleSnapshot::availableForManualTidy)
|
||||
.count();
|
||||
int assigned = assignedManualVehicleIds == null ? 0 : (int) assignedManualVehicleIds.stream()
|
||||
.filter(id -> vehicles.stream().anyMatch(v -> v.vehicleId().equals(id)
|
||||
&& v.online() && v.healthy()))
|
||||
.count();
|
||||
int available = Math.max(0, effective - Math.max(activeManualWorkflows, assigned));
|
||||
return new CapacityResult(effective, available);
|
||||
if (maxConcurrency <= 0) {
|
||||
return new CapacityResult(effective, 0);
|
||||
}
|
||||
int remainingByLimit = Math.max(0, maxConcurrency - Math.max(0, activeManualWorkflows));
|
||||
return new CapacityResult(effective, Math.min(effective, remainingByLimit));
|
||||
}
|
||||
|
||||
public record CapacityResult(int effectiveCapacity, int newSlots) { }
|
||||
|
||||
@@ -194,6 +194,7 @@ fortucky:
|
||||
tidy-auto-scan-ms: ${FORTUCKY_TIDY_AUTO_SCAN_MS:600000}
|
||||
tidy-reconcile-ms: ${FORTUCKY_TIDY_RECONCILE_MS:5000}
|
||||
tidy-claim-lease-seconds: ${FORTUCKY_TIDY_CLAIM_LEASE_SECONDS:60}
|
||||
tidy-manual-max-concurrency: ${FORTUCKY_TIDY_MANUAL_MAX_CONCURRENCY:3}
|
||||
tidy-wcs-snapshot-url: ${FORTUCKY_TIDY_WCS_SNAPSHOT_URL:http://localhost:8081/wcs2Wms/api/tidyVehicleSnapshot}
|
||||
replay-run-id: ${FT546_REPLAY_RUN_ID:}
|
||||
replay-database: ${FT546_REPLAY_DATABASE:}
|
||||
|
||||
@@ -245,21 +245,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
)
|
||||
</select>
|
||||
|
||||
<select id="selectActiveManualTidyVehicleIds" resultType="string">
|
||||
select vehicle_id
|
||||
from alg.tidy_workflow
|
||||
where warehouse_id = #{warehouseId}
|
||||
and origin = 'MANUAL_TIDY'
|
||||
and vehicle_id is not null
|
||||
and status in ('CLAIMED', 'WAITING_DISPATCH', 'DISPATCHED', 'RUNNING', 'PAUSED_RECOVERY')
|
||||
</select>
|
||||
|
||||
<update id="assignTidyWorkflowVehicle">
|
||||
update alg.tidy_workflow
|
||||
set vehicle_id = #{vehicleId}, updated_at = SYSUTCDATETIME()
|
||||
where workflow_id = #{workflowId} and vehicle_id is null
|
||||
</update>
|
||||
|
||||
<select id="selectInboundTaskItems" resultType="com.ruoyi.algorithm.wms.InboundTaskItem">
|
||||
select coalesce(nullif(ltrim(rtrim(isd.spare4)), ''),
|
||||
nullif(ltrim(rtrim(bs.spare4)), '')) as projectNo,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.ruoyi.algorithm.wms.tidy;
|
||||
|
||||
import com.ruoyi.algorithm.integration.AlgorithmProperties;
|
||||
import com.ruoyi.algorithm.integration.SchedulingFacade;
|
||||
import com.ruoyi.algorithm.integration.SchedulingMode;
|
||||
import com.ruoyi.algorithm.tidy.TidyCandidate;
|
||||
import com.ruoyi.algorithm.tidy.TidyCandidateType;
|
||||
import com.ruoyi.algorithm.tidy.TidyMove;
|
||||
import com.ruoyi.algorithm.tidy.TidyPlan;
|
||||
import com.ruoyi.algorithm.wms.WmsAlgorithmMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ManualTidySchedulerTest {
|
||||
@Mock
|
||||
private TidyModeService modeService;
|
||||
@Mock
|
||||
private WcsVehicleSnapshotClient vehicleClient;
|
||||
@Mock
|
||||
private SchedulingFacade facade;
|
||||
@Mock
|
||||
private TidyWorkflowTransaction workflows;
|
||||
@Mock
|
||||
private TidyWorkflowRecoveryService recovery;
|
||||
@Mock
|
||||
private WmsAlgorithmMapper mapper;
|
||||
|
||||
@Test
|
||||
void startsPlannerCandidatesUpToIdleAndConfiguredCapacity() {
|
||||
ManualTidyScheduler scheduler = scheduler(3);
|
||||
|
||||
TidyCandidate first = candidate("TRAY-1", "A-01", "B-01");
|
||||
TidyCandidate second = candidate("TRAY-2", "A-02", "B-02");
|
||||
enableManualTidyWithTwoIdleVehicles();
|
||||
when(mapper.countActiveManualTidy("01")).thenReturn(1);
|
||||
plan(first, second);
|
||||
|
||||
scheduler.reconcileAndFill();
|
||||
|
||||
ArgumentCaptor<TidyCandidate> candidates = ArgumentCaptor.forClass(TidyCandidate.class);
|
||||
verify(workflows, times(2)).start(eq(TidyOrigin.MANUAL_TIDY), eq("01"), candidates.capture());
|
||||
assertThat(candidates.getAllValues()).containsExactly(first, second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void limitsPlannerCandidatesToConfiguredMaximum() {
|
||||
ManualTidyScheduler scheduler = scheduler(1);
|
||||
TidyCandidate first = candidate("TRAY-1", "A-01", "B-01");
|
||||
TidyCandidate second = candidate("TRAY-2", "A-02", "B-02");
|
||||
enableManualTidyWithTwoIdleVehicles();
|
||||
when(mapper.countActiveManualTidy("01")).thenReturn(0);
|
||||
plan(first, second);
|
||||
|
||||
scheduler.reconcileAndFill();
|
||||
|
||||
ArgumentCaptor<TidyCandidate> candidates = ArgumentCaptor.forClass(TidyCandidate.class);
|
||||
verify(workflows).start(eq(TidyOrigin.MANUAL_TIDY), eq("01"), candidates.capture());
|
||||
assertThat(candidates.getValue()).isEqualTo(first);
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesWorkflowFailureSoTheSchedulingTransactionRollsBack() {
|
||||
ManualTidyScheduler scheduler = scheduler(2);
|
||||
TidyCandidate first = candidate("TRAY-1", "A-01", "B-01");
|
||||
TidyCandidate second = candidate("TRAY-2", "A-02", "B-02");
|
||||
enableManualTidyWithTwoIdleVehicles();
|
||||
when(mapper.countActiveManualTidy("01")).thenReturn(0);
|
||||
plan(first, second);
|
||||
when(workflows.start(TidyOrigin.MANUAL_TIDY, "01", first))
|
||||
.thenThrow(new IllegalStateException("insert failed"));
|
||||
|
||||
assertThatThrownBy(scheduler::reconcileAndFill)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("insert failed");
|
||||
|
||||
verify(workflows).start(TidyOrigin.MANUAL_TIDY, "01", first);
|
||||
verify(workflows, times(1)).start(eq(TidyOrigin.MANUAL_TIDY), eq("01"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void holdsTheModeRowLockForTheWholeSchedulingTransaction() throws NoSuchMethodException {
|
||||
Method method = ManualTidyScheduler.class.getMethod("reconcileAndFill");
|
||||
|
||||
var attribute = new AnnotationTransactionAttributeSource()
|
||||
.getTransactionAttribute(method, ManualTidyScheduler.class);
|
||||
|
||||
assertThat(attribute).isNotNull();
|
||||
assertThat(attribute.rollbackOn(new RuntimeException("failure"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotPlanWhenNoVehicleIsAvailable() {
|
||||
ManualTidyScheduler scheduler = scheduler(3);
|
||||
when(modeService.reconcile("01")).thenReturn(new TidyModeSnapshot(
|
||||
"01", true, TidyEffectiveState.TIDY_ACTIVE, 0, 0, "", 1, "test", null));
|
||||
when(vehicleClient.fetch()).thenReturn(List.of());
|
||||
when(mapper.countActiveManualTidy("01")).thenReturn(0);
|
||||
|
||||
scheduler.reconcileAndFill();
|
||||
|
||||
verifyNoInteractions(facade, workflows);
|
||||
}
|
||||
|
||||
private ManualTidyScheduler scheduler(int maxConcurrency) {
|
||||
AlgorithmProperties properties = new AlgorithmProperties();
|
||||
properties.setMode(SchedulingMode.ACTIVE);
|
||||
properties.setWarehouseId("01");
|
||||
properties.setTidyManualMaxConcurrency(maxConcurrency);
|
||||
return new ManualTidyScheduler(modeService, vehicleClient,
|
||||
facade, workflows, recovery, mapper, properties);
|
||||
}
|
||||
|
||||
private void enableManualTidyWithTwoIdleVehicles() {
|
||||
when(modeService.reconcile("01")).thenReturn(new TidyModeSnapshot(
|
||||
"01", true, TidyEffectiveState.TIDY_ACTIVE, 0, 1, "", 1, "test", null));
|
||||
when(vehicleClient.fetch()).thenReturn(List.of(
|
||||
new VehicleSnapshot("A", true, true, true, ""),
|
||||
new VehicleSnapshot("B", true, true, true, "")));
|
||||
}
|
||||
|
||||
private void plan(TidyCandidate... candidates) {
|
||||
when(facade.planTidy(eq("01"), any(Instant.class)))
|
||||
.thenReturn(new TidyPlan(List.of(candidates), List.of()));
|
||||
}
|
||||
|
||||
private static TidyCandidate candidate(String tray, String source, String target) {
|
||||
return new TidyCandidate(TidyCandidateType.GUEST_RETURN, "PROJECT-1",
|
||||
List.of(new TidyMove(tray, source, target)), Optional.empty(), "test");
|
||||
}
|
||||
}
|
||||
@@ -3,30 +3,71 @@ package com.ruoyi.algorithm.wms.tidy;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TidyVehicleCapacityTest {
|
||||
@Test
|
||||
void countsOnlyHealthyOnlineIdleVehiclesAndLeavesRunningWorkInCapacity() {
|
||||
VehicleSnapshot healthyIdle = new VehicleSnapshot("1", true, true, true, null);
|
||||
VehicleSnapshot healthyTidy = new VehicleSnapshot("2", true, true, false, "MANUAL_TIDY");
|
||||
VehicleSnapshot busyInbound = new VehicleSnapshot("3", true, true, false, "INBOUND");
|
||||
void doesNotSubtractRunningWorkflowFromAlreadyIdleSnapshot() {
|
||||
VehicleSnapshot firstIdle = new VehicleSnapshot("1", true, true, true, null);
|
||||
VehicleSnapshot secondIdle = new VehicleSnapshot("2", true, true, true, null);
|
||||
VehicleSnapshot busyTidy = new VehicleSnapshot("3", true, true, false, "MANUAL_TIDY");
|
||||
|
||||
TidyVehicleCapacity.CapacityResult result = new TidyVehicleCapacity().calculate(
|
||||
List.of(healthyIdle, healthyTidy, busyInbound), 1, Set.of("2"));
|
||||
List.of(firstIdle, secondIdle, busyTidy), 1, 3);
|
||||
|
||||
assertThat(result.effectiveCapacity()).isEqualTo(1);
|
||||
assertThat(result.newSlots()).isEqualTo(0);
|
||||
assertThat(result.effectiveCapacity()).isEqualTo(2);
|
||||
assertThat(result.newSlots()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void offlineOrAlarmedAssignedVehicleCannotConsumeNewCapacity() {
|
||||
void limitsNewSlotsByConfiguredConcurrency() {
|
||||
List<VehicleSnapshot> vehicles = List.of(
|
||||
new VehicleSnapshot("1", true, true, true, null),
|
||||
new VehicleSnapshot("2", true, true, true, null),
|
||||
new VehicleSnapshot("3", true, true, true, null));
|
||||
|
||||
TidyVehicleCapacity.CapacityResult result = new TidyVehicleCapacity().calculate(
|
||||
List.of(new VehicleSnapshot("1", false, false, false, "MANUAL_TIDY"),
|
||||
new VehicleSnapshot("2", true, true, true, null)), 0, Set.of("1"));
|
||||
assertThat(result.effectiveCapacity()).isEqualTo(1);
|
||||
vehicles, 1, 2);
|
||||
|
||||
assertThat(result.effectiveCapacity()).isEqualTo(3);
|
||||
assertThat(result.newSlots()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNoSlotsWhenConcurrencyLimitIsReached() {
|
||||
List<VehicleSnapshot> vehicles = List.of(
|
||||
new VehicleSnapshot("1", true, true, true, null),
|
||||
new VehicleSnapshot("2", true, true, true, null));
|
||||
|
||||
TidyVehicleCapacity.CapacityResult result = new TidyVehicleCapacity().calculate(
|
||||
vehicles, 2, 2);
|
||||
|
||||
assertThat(result.effectiveCapacity()).isEqualTo(2);
|
||||
assertThat(result.newSlots()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableVehiclesDoNotProduceSlots() {
|
||||
List<VehicleSnapshot> vehicles = List.of(
|
||||
new VehicleSnapshot("1", false, false, false, null),
|
||||
new VehicleSnapshot("2", true, false, true, null),
|
||||
new VehicleSnapshot("3", true, true, false, "INBOUND"));
|
||||
|
||||
TidyVehicleCapacity.CapacityResult result = new TidyVehicleCapacity().calculate(
|
||||
vehicles, 0, 3);
|
||||
|
||||
assertThat(result.effectiveCapacity()).isZero();
|
||||
assertThat(result.newSlots()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonPositiveConcurrencyDisablesNewWorkflows() {
|
||||
List<VehicleSnapshot> vehicles = List.of(
|
||||
new VehicleSnapshot("1", true, true, true, null));
|
||||
|
||||
assertThat(new TidyVehicleCapacity().calculate(vehicles, 0, 0).newSlots()).isZero();
|
||||
assertThat(new TidyVehicleCapacity().calculate(vehicles, 0, -1).newSlots()).isZero();
|
||||
assertThat(new TidyVehicleCapacity().calculate(vehicles, 1, Integer.MIN_VALUE).newSlots()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user