fix: repair shadow candidate planning and site runtime errors

This commit is contained in:
zgw
2026-09-09 09:57:28 +08:00
parent 1a34d72cbb
commit 0bd47e2b2c
27 changed files with 788 additions and 203 deletions

View File

@@ -472,6 +472,7 @@ public final class InboundPlanner {
}
private boolean usableSlot(Lane lane, SpaceState space, InboundRequest request, InboundContext context) {
if (request.eligibleSpaceIds() != null && !request.eligibleSpaceIds().contains(space.spaceId())) return false;
if (!lane.usableForInbound() || !space.availableEmpty() || space.role() != SpaceRole.STORAGE) return false;
if (request.targetFloor() != null && space.floorNo() != request.targetFloor()) return false;
if (request.targetFloor() == null && !request.allowedFloors().isEmpty()

View File

@@ -12,8 +12,16 @@ public record InboundRequest(
Integer entryFloor,
Integer targetFloor,
Set<Integer> allowedFloors,
BatchLaneMode batchLaneMode
BatchLaneMode batchLaneMode,
Set<String> eligibleSpaceIds
) {
public InboundRequest(String requestId, String trayNo, String projectNo, String productId, String lotNo,
Integer expectedProjectPallets, Integer entryFloor, Integer targetFloor,
Set<Integer> allowedFloors, BatchLaneMode batchLaneMode) {
this(requestId, trayNo, projectNo, productId, lotNo, expectedProjectPallets, entryFloor, targetFloor,
allowedFloors, batchLaneMode, null);
}
public InboundRequest {
if (requestId == null || requestId.isBlank()) throw new IllegalArgumentException("requestId is required");
if (trayNo == null || trayNo.isBlank()) throw new IllegalArgumentException("trayNo is required");
@@ -22,5 +30,7 @@ public record InboundRequest(
}
allowedFloors = allowedFloors == null ? Set.of() : Set.copyOf(allowedFloors);
batchLaneMode = batchLaneMode == null ? BatchLaneMode.OPEN : batchLaneMode;
// null means unrestricted; an empty captured set means no eligible destination.
eligibleSpaceIds = eligibleSpaceIds == null ? null : Set.copyOf(eligibleSpaceIds);
}
}

View File

@@ -60,6 +60,14 @@ public final class SchedulingFacade {
return applicationService.planOutboundFromPublishedSnapshot(warehouseId, request);
}
public OutboundPlan planShadowOutboundCandidates(String warehouseId, OutboundRequest request,
java.util.Set<String> eligibleStockIds) {
if (properties.getMode() != SchedulingMode.SHADOW) {
throw new IllegalStateException("Captured candidate planning requires SHADOW mode");
}
return applicationService.planShadowOutboundCandidates(warehouseId, request, eligibleStockIds);
}
public TidyPlan planTidy(String warehouseId, Instant now) {
requireAlgorithmMode();
return applicationService.planTidy(warehouseId, now);

View File

@@ -18,7 +18,14 @@ import java.util.Optional;
public final class OutboundPlanner {
public OutboundPlan plan(OutboundRequest request, OutboundContext context) {
return plan(request, context, null);
}
public OutboundPlan plan(OutboundRequest request, OutboundContext context, java.util.Set<String> eligibleSpaceIds) {
String mode = normalizedMode(request.fifoMode());
if (eligibleSpaceIds != null && !"OFF".equals(mode)) {
throw new IllegalArgumentException("Captured candidate planning supports FIFO OFF only");
}
if (!List.of("OFF", "STRICT", "FLEXIBLE").contains(mode)) {
return error(OutboundErrorCode.FIFO_INVALID_MODE, "Unsupported FIFO mode",
Map.of("fifoMode", String.valueOf(request.fifoMode())));
@@ -35,6 +42,7 @@ public final class OutboundPlanner {
List<SpaceState> physicalMatches = context.spaces().stream()
.filter(SpaceState::occupied)
.filter(space -> eligibleSpaceIds == null || eligibleSpaceIds.contains(space.spaceId()))
.filter(space -> matchesRequest(space, request))
.toList();
if (physicalMatches.isEmpty()) {

View File

@@ -359,6 +359,17 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
@Override
public List<LaneSnapshot> loadLaneSnapshots(String warehouseId, Collection<String> laneCodes) {
return loadLaneSnapshots(warehouseId, laneCodes, Set.of());
}
@Override
public List<LaneSnapshot> loadShadowOutboundLaneSnapshots(String warehouseId, Collection<String> laneCodes,
Set<String> candidateSpaceIds) {
return loadLaneSnapshots(warehouseId, laneCodes, Set.copyOf(candidateSpaceIds));
}
private List<LaneSnapshot> loadLaneSnapshots(String warehouseId, Collection<String> laneCodes,
Set<String> mixedProjectSources) {
LinkedHashSet<String> codes = laneCodes == null ? new LinkedHashSet<>()
: laneCodes.stream().filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
if (codes.isEmpty()) return List.of();
@@ -393,7 +404,7 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
int capacity = entry.getValue().size();
List<SpaceSnapshot> spaces = entry.getValue().stream()
.map(raw -> mapSpace(raw, capacity, versions.get(entry.getKey()),
inventoryBySpace.getOrDefault(raw.spaceId(), List.of())))
inventoryBySpace.getOrDefault(raw.spaceId(), List.of()), mixedProjectSources.contains(raw.spaceId())))
.sorted(Comparator.comparingInt((SpaceSnapshot value) -> value.state().depthOrder())
.thenComparing(SpaceSnapshot::spaceId)).toList();
result.add(new LaneSnapshot(warehouseId, entry.getKey(), versions.get(entry.getKey()), spaces));
@@ -720,6 +731,11 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
}
private SpaceSnapshot mapSpace(RawSpace raw, int capacity, VersionToken version, List<InventoryItem> inventory) {
return mapSpace(raw, capacity, version, inventory, false);
}
private SpaceSnapshot mapSpace(RawSpace raw, int capacity, VersionToken version,
List<InventoryItem> inventory, boolean allowMixedProjectSource) {
EnumSet<SpaceConflict> conflicts = EnumSet.noneOf(SpaceConflict.class);
if (raw.trayConflict()) conflicts.add(SpaceConflict.TRAY);
if (raw.projectConflict()) conflicts.add(SpaceConflict.PROJECT);
@@ -734,7 +750,8 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
Instant inboundAt = inventory.stream().map(InventoryItem::inboundAt).filter(Objects::nonNull)
.min(Comparator.naturalOrder()).orElse(null);
boolean invalid = locked(raw.locked()) || raw.reserved() || raw.hasGap() || raw.depth() == null
|| !conflicts.isEmpty() || ("1".equals(raw.spaceStatus()) && (!inventory.isEmpty() || tray != null));
|| conflicts.stream().anyMatch(conflict -> conflict != SpaceConflict.PROJECT || !allowMixedProjectSource)
|| ("1".equals(raw.spaceStatus()) && (!inventory.isEmpty() || tray != null));
SpaceStatus status = invalid ? SpaceStatus.DISABLED : switch (Objects.toString(raw.spaceStatus(), "")) {
case "1" -> SpaceStatus.EMPTY;
case "2" -> SpaceStatus.OCCUPIED;

View File

@@ -16,6 +16,10 @@ public interface WarehouseRepository {
LanePage pageCandidateLanes(String warehouseId, String afterLaneCode, int pageSize);
LanePage pageWarehouseLanes(String warehouseId, String afterLaneCode, int pageSize);
List<LaneSnapshot> loadLaneSnapshots(String warehouseId, Collection<String> laneCodes);
default List<LaneSnapshot> loadShadowOutboundLaneSnapshots(String warehouseId, Collection<String> laneCodes,
Set<String> candidateSpaceIds) {
return loadLaneSnapshots(warehouseId, laneCodes);
}
List<SpaceSnapshot> loadRelocationBuffers(String warehouseId, Set<Integer> floors);
Set<Integer> loadActiveDeepWorkflowFloors(String warehouseId, String excludingRequestId);
boolean acquireDeepWorkflowLock(String requestId, String warehouseId, int floorNo);

View File

@@ -98,7 +98,8 @@ public final class WarehouseApplicationService {
int currentProjectPallets = repository.countProjectPallets(warehouseId, request.projectNo());
effectiveRequest = new InboundRequest(request.requestId(), request.trayNo(), request.projectNo(),
request.productId(), request.lotNo(), currentProjectPallets <= 1 ? 2 : 4,
request.entryFloor(), request.targetFloor(), request.allowedFloors(), request.batchLaneMode());
request.entryFloor(), request.targetFloor(), request.allowedFloors(), request.batchLaneMode(),
request.eligibleSpaceIds());
}
List<PendingOutbound> pending = repository.loadPendingOutboundJobs(warehouseId).stream()
.map(job -> new PendingOutbound(job.projectNo(), job.productNo(), job.trayNo(),
@@ -136,6 +137,18 @@ public final class WarehouseApplicationService {
return planOutbound(warehouseId, request, false);
}
/** The caller holds a published snapshot lease and supplies the site's eligible inventory IDs. */
public OutboundPlan planShadowOutboundCandidates(String warehouseId, OutboundRequest request,
Set<String> eligibleStockIds) {
Objects.requireNonNull(eligibleStockIds, "eligibleStockIds");
if (eligibleStockIds.size() > 200 || request.wholeTrayOrDefault() || nonBlank(request.trayNo())
|| !nonBlank(request.projectNo()) || !nonBlank(request.productId())
|| (nonBlank(request.fifoMode()) && !"OFF".equalsIgnoreCase(request.fifoMode()))) {
throw new IllegalArgumentException("Shadow candidates require a bounded project/product picking scope with FIFO OFF");
}
return planOutboundFromCurrentReadModel(warehouseId, request, Set.copyOf(eligibleStockIds));
}
private OutboundPlan planOutbound(String warehouseId, OutboundRequest request, boolean refresh) {
if (refresh) {
repository.refreshChangedLanes(warehouseId);
@@ -144,6 +157,11 @@ public final class WarehouseApplicationService {
}
private OutboundPlan planOutboundFromCurrentReadModel(String warehouseId, OutboundRequest request) {
return planOutboundFromCurrentReadModel(warehouseId, request, null);
}
private OutboundPlan planOutboundFromCurrentReadModel(String warehouseId, OutboundRequest request,
Set<String> eligibleStockIds) {
RepositoryPolicy policy = repository.loadPolicy(warehouseId);
WarehouseSummary summary = repository.loadSummary(warehouseId);
OutboundRequest effectiveRequest = request.fifoToleranceMinutes() == null && policy.fifoToleranceMinutes() > 0
@@ -187,18 +205,28 @@ public final class WarehouseApplicationService {
matches.addAll(repository.searchOutboundStock(warehouseId,
OutboundStockSearch.project(request.projectNo(), request.productId())));
}
if (eligibleStockIds != null) {
matches.removeIf(match -> !eligibleStockIds.contains(match.stockId()));
Set<String> resolved = matches.stream().map(OutboundStockMatch::stockId).collect(java.util.stream.Collectors.toSet());
if (!resolved.equals(eligibleStockIds)) {
throw new IllegalStateException("Captured outbound candidate is missing from the published inventory");
}
}
Set<String> matchedSpaceIds = matches.stream().map(OutboundStockMatch::spaceId)
.filter(this::nonBlank).collect(java.util.stream.Collectors.toSet());
List<String> laneCodes = matches.stream().map(OutboundStockMatch::laneCode).filter(Objects::nonNull)
.distinct().sorted().toList();
List<LaneSnapshot> lanes = repository.loadLaneSnapshots(warehouseId, laneCodes);
List<LaneSnapshot> lanes = eligibleStockIds == null ? repository.loadLaneSnapshots(warehouseId, laneCodes)
: repository.loadShadowOutboundLaneSnapshots(warehouseId, laneCodes, matchedSpaceIds);
Set<Integer> floors = matches.stream().map(OutboundStockMatch::floorNo)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
List<SpaceSnapshot> buffers = repository.loadRelocationBuffers(warehouseId, floors);
Map<String, SpaceState> uniqueSpaces = new LinkedHashMap<>();
Set<String> matchedSpaceIds = matches.stream().map(OutboundStockMatch::spaceId)
.filter(this::nonBlank).collect(java.util.stream.Collectors.toSet());
String productScope = matchedProductScope;
lanes.stream().flatMap(lane -> lane.spaceStates().stream()).forEach(space -> uniqueSpaces.put(space.spaceId(),
productScope != null && matchedSpaceIds.contains(space.spaceId())
eligibleStockIds != null && matchedSpaceIds.contains(space.spaceId())
? withInventoryScope(space, request.projectNo(), request.productId())
: productScope != null && matchedSpaceIds.contains(space.spaceId())
? withProductScope(space, productScope) : space));
buffers.stream().map(SpaceSnapshot::state).forEach(space -> uniqueSpaces.put(space.spaceId(), space));
Map<String, String> rawInboundTimes = new LinkedHashMap<>();
@@ -210,7 +238,8 @@ public final class WarehouseApplicationService {
OutboundPlan plan = outboundPlanner.plan(effectiveRequest,
new OutboundContext(List.copyOf(uniqueSpaces.values()), rawInboundTimes,
capacityMode(summary, policy),
repository.loadActiveDeepWorkflowFloors(warehouseId, request.requestId())));
repository.loadActiveDeepWorkflowFloors(warehouseId, request.requestId())),
eligibleStockIds == null ? null : matchedSpaceIds);
String resultCode = switch (plan.status()) {
case READY -> "READY";
case WAITING -> "WAITING_" + plan.waiting().orElseThrow().code().name();
@@ -312,10 +341,14 @@ public final class WarehouseApplicationService {
}
private SpaceState withProductScope(SpaceState space, String productId) {
return withInventoryScope(space, space.projectNo(), productId);
}
private SpaceState withInventoryScope(SpaceState space, String projectNo, String productId) {
return new SpaceState(space.spaceId(), space.zoneCode(), space.laneCode(), space.depthOrder(),
space.laneCapacity(), space.floorNo(), space.aisleNo(), space.groupCoding(), space.rowCode(),
space.role(), space.status(), space.outboundTaskActive(), space.inventoryProjectConflict(),
space.trayNo(), space.projectNo(), productId, space.lotNo(), space.inboundAt(), space.outboundAt(),
space.trayNo(), projectNo, productId, space.lotNo(), space.inboundAt(), space.outboundAt(),
space.reserved(), space.rowVersion(), space.physicalStockPresent());
}

View File

@@ -104,6 +104,33 @@ class JdbcWarehouseRepositoryTest {
assertThat(repository.countProjectPallets("W1", "PROJECT-W2")).isZero();
}
@Test
void mixedProjectPalletIsSelectableByItsEligibleInventoryItem() throws Exception {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('MIXED-SOURCE','W1','R','A','Mixed pallet',9,91,1,'2','',"
+ "'MIXED-TRAY','MIXED-LANE','1')");
execute("INSERT INTO BAS_STOCK VALUES ('MIX-1','MIXED-SOURCE','MIXED-TRAY','P1','SKU1','LOT','2026-01-01 09:00:00',5,0)");
execute("INSERT INTO BAS_STOCK VALUES ('MIX-2','MIXED-SOURCE','MIXED-TRAY','P2','SKU2','LOT','2026-01-01 09:00:00',5,0)");
rebuildTopology("W1");
var service = new WarehouseApplicationService(repository, new InboundPlanner(), new OutboundPlanner(), new TidyPlanner(), 100);
var request = new OutboundRequest("MIX-PICK", null, "P1", "SKU1", "OFF", null, 1, false);
var plan = service.planShadowOutboundCandidates("W1", request, Set.of("MIX-1"));
assertThat(plan.status()).isEqualTo(OutboundPlanStatus.READY);
assertThat(plan.assignment().orElseThrow().sourceSpaceId()).isEqualTo("MIXED-SOURCE");
assertThat(plan.assignment().orElseThrow().projectNo()).isEqualTo("P1");
assertThat(plan.assignment().orElseThrow().productId()).isEqualTo("SKU1");
assertThat(space(repository.loadLaneSnapshots("W1", List.of("MIXED-LANE")), "MIXED-SOURCE")
.state().outboundSelectable()).isFalse();
for (String columnValue : List.of("locked='Y'", "status='4'", "tray_code='WRONG'")) {
execute("UPDATE BAS_CARGO_SPACE SET " + columnValue + " WHERE cargo_space_id='MIXED-SOURCE'");
rebuildTopology("W1");
assertThat(service.planShadowOutboundCandidates("W1", request, Set.of("MIX-1")).status())
.isNotEqualTo(OutboundPlanStatus.READY);
execute("UPDATE BAS_CARGO_SPACE SET locked='',status='2',tray_code='MIXED-TRAY' WHERE cargo_space_id='MIXED-SOURCE'");
}
}
@Test
void statusFourPhysicalOuterPalletIsPreservedAndMakesDeepOutboundWait() throws Exception {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('ALLOC-TARGET','W1','RA','A','Target',7,21,2,'2','',"

View File

@@ -0,0 +1,89 @@
package com.ruoyi.algorithm.service;
import com.ruoyi.algorithm.inbound.*;
import com.ruoyi.algorithm.integration.*;
import com.ruoyi.algorithm.model.*;
import com.ruoyi.algorithm.outbound.*;
import com.ruoyi.algorithm.repository.*;
import com.ruoyi.algorithm.tidy.TidyPlanner;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ShadowCandidatePlanningTest {
@Test
void ineligibleOuterStockRemainsAPhysicalBlockerInsteadOfBeingChosenOrRemoved() {
var repository = mock(WarehouseRepository.class);
when(repository.loadPolicy("01")).thenReturn(RepositoryPolicy.defaults());
when(repository.loadSummary("01")).thenReturn(new WarehouseSummary("01", 1, 2, 2, 0, 0, 0, 0));
when(repository.searchOutboundStock(eq("01"), any())).thenReturn(List.of(
new OutboundStockMatch("INNER", "L-1", "L", 1, "T1", "P", "SKU", "LOT", null, null),
new OutboundStockMatch("OUTER", "L-2", "L", 1, "T2", "P", "SKU", "LOT", null, null)));
var source = state("L", 1, SpaceStatus.OCCUPIED, "T1", null);
var blocker = state("L", 2, SpaceStatus.OCCUPIED, "T2", "P");
when(repository.loadShadowOutboundLaneSnapshots("01", List.of("L"), Set.of("L-1")))
.thenReturn(List.of(lane("L", source, blocker)));
var service = new WarehouseApplicationService(repository, new InboundPlanner(), new OutboundPlanner(), new TidyPlanner(), 100);
var request = new OutboundRequest("O", null, "P", "SKU", "OFF", null, 1, false);
var plan = service.planShadowOutboundCandidates("01", request, Set.of("INNER"));
assertThat(plan.waiting()).isPresent();
assertThat(plan.waiting().orElseThrow().code()).isEqualTo(OutboundWaitingCode.WAITING_RELOCATION_BUFFER);
assertThat(plan.waiting().orElseThrow().details().get("sourceSpaceId")).isEqualTo("L-1");
verify(repository, never()).synchronizeReadModel(any());
verify(repository, never()).refreshChangedLanes(any());
verify(repository, never()).tryReserveInbound(any());
}
@Test
void eligibleInboundSetSurvivesProjectSizeResolutionAndPageBoundaries() {
var repository = mock(WarehouseRepository.class);
when(repository.loadPolicy("01")).thenReturn(RepositoryPolicy.defaults());
when(repository.loadSummary("01")).thenReturn(new WarehouseSummary("01", 2, 4, 0, 4, 0, 0, 0));
when(repository.pageCandidateLanes("01", null, 1)).thenReturn(new LanePage(
List.of(new LaneCandidate("01", "A", 2, 0, 2, false, false, VersionToken.numeric(1))), "A"));
when(repository.pageCandidateLanes("01", "A", 1)).thenReturn(new LanePage(
List.of(new LaneCandidate("01", "Z", 2, 0, 2, false, false, VersionToken.numeric(2))), null));
when(repository.loadLaneSnapshots("01", List.of("A"))).thenReturn(List.of(lane("A",
state("A", 1, SpaceStatus.EMPTY, null, null), state("A", 2, SpaceStatus.EMPTY, null, null))));
when(repository.loadLaneSnapshots("01", List.of("Z"))).thenReturn(List.of(lane("Z",
state("Z", 1, SpaceStatus.EMPTY, null, null), state("Z", 2, SpaceStatus.EMPTY, null, null))));
var service = new WarehouseApplicationService(repository, new InboundPlanner(), new OutboundPlanner(), new TidyPlanner(), 1);
var request = new InboundRequest("I", "T", "P", null, null, null, 1, null, Set.of(), BatchLaneMode.OPEN, Set.of("Z-1"));
assertThat(service.planInboundFromPublishedSnapshot("01", request).plan().assignment().orElseThrow().spaceId())
.isEqualTo("Z-1");
var forbidden = new InboundRequest("I", "T", "P", null, null, null, 1, null, Set.of(), BatchLaneMode.OPEN, Set.of());
assertThat(service.planInboundFromPublishedSnapshot("01", forbidden).plan().assignment()).isEmpty();
verify(repository, never()).refreshChangedLanes(any());
}
@Test
void candidateProjectionCannotBeCalledThroughLegacyOrActiveFacade() {
for (var mode : List.of(SchedulingMode.LEGACY, SchedulingMode.ACTIVE)) {
var props = new AlgorithmProperties();
props.setMode(mode);
var service = mock(WarehouseApplicationService.class);
var facade = new SchedulingFacade(service, props);
assertThatThrownBy(() -> facade.planShadowOutboundCandidates("01",
new OutboundRequest("O", null, "P", "SKU", "OFF", null, 1, false), Set.of("S")))
.isInstanceOf(IllegalStateException.class).hasMessageContaining("SHADOW");
verifyNoInteractions(service);
}
}
private LaneSnapshot lane(String code, SpaceState... spaces) {
return new LaneSnapshot("01", code, VersionToken.numeric(1), java.util.Arrays.stream(spaces)
.map(s -> new SpaceSnapshot(s, List.of(), Set.of(), s.availableEmpty(), false, null)).toList());
}
private SpaceState state(String lane, int depth, SpaceStatus status, String tray, String project) {
return new SpaceState(lane + "-" + depth, "A", lane, depth, 2, 1, 1, depth, "1", SpaceRole.STORAGE,
status, false, false, tray, project, "SKU", "LOT", Instant.EPOCH, null, false, null);
}
}