fix: decouple manual tidy from vehicle assignment

This commit is contained in:
zgw
2026-07-24 11:37:43 +08:00
parent 947edbdbc8
commit 8b84459072
11 changed files with 240 additions and 66 deletions

View File

@@ -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,

View File

@@ -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;
}
}
}

View File

@@ -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) { }

View File

@@ -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:}

View File

@@ -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,

View File

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

View File

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