feat: add algorithm core for shadow observation

This commit is contained in:
zgw
2026-08-11 16:59:43 +08:00
parent 9480882752
commit 86fc240b84
99 changed files with 65211 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
package com.ruoyi.algorithm;
import com.ruoyi.algorithm.model.LaneState;
import com.ruoyi.algorithm.model.SpaceRole;
import com.ruoyi.algorithm.model.SpaceState;
import com.ruoyi.algorithm.model.SpaceStatus;
import com.ruoyi.algorithm.policy.SchedulingPolicy;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class LaneAnalyzerTest {
private final LaneAnalyzer analyzer = new LaneAnalyzer();
@Test
void findsNextSlotFromInsideToOutside() {
LaneState lane = analyzer.analyze(List.of(
space("S1", 1, SpaceStatus.OCCUPIED, "T1", "P1"),
space("S2", 2, SpaceStatus.OCCUPIED, "T2", "P1"),
space("S3", 3, SpaceStatus.EMPTY, null, null),
space("S4", 4, SpaceStatus.EMPTY, null, null),
space("S5", 5, SpaceStatus.EMPTY, null, null)
), SchedulingPolicy.ft546Defaults());
assertThat(lane.physicallyContinuous()).isTrue();
assertThat(lane.nextSlot()).isPresent();
assertThat(lane.nextSlot().orElseThrow().spaceId()).isEqualTo("S3");
assertThat(lane.residentProjectNo()).contains("P1");
assertThat(lane.segmentCount()).isEqualTo(1);
}
@Test
void detectsInnerHoleAndRefusesNextSlot() {
LaneState lane = analyzer.analyze(List.of(
space("S1", 1, SpaceStatus.OCCUPIED, "T1", "P1"),
space("S2", 2, SpaceStatus.EMPTY, null, null),
space("S3", 3, SpaceStatus.OCCUPIED, "T3", "P1")
), SchedulingPolicy.ft546Defaults());
assertThat(lane.physicallyContinuous()).isFalse();
assertThat(lane.nextSlot()).isEmpty();
}
@Test
void detectsResidentAndSingleOuterGuestSegments() {
LaneState lane = analyzer.analyze(List.of(
space("S1", 1, SpaceStatus.OCCUPIED, "T1", "P1"),
space("S2", 2, SpaceStatus.OCCUPIED, "T2", "P1"),
space("S3", 3, SpaceStatus.OCCUPIED, "T3", "P2"),
space("S4", 4, SpaceStatus.EMPTY, null, null)
), SchedulingPolicy.ft546Defaults());
assertThat(lane.segmentCount()).isEqualTo(2);
assertThat(lane.residentProjectNo()).contains("P1");
assertThat(lane.guestProjectNo()).contains("P2");
assertThat(lane.corrupt()).isFalse();
assertThat(lane.outerSlot().orElseThrow().spaceId()).isEqualTo("S4");
}
@Test
void outerSlotIsTheFirstEmptyPositionImmediatelyOutsideStock() {
LaneState lane = analyzer.analyze(List.of(
space("S1", 1, SpaceStatus.OCCUPIED, "T1", "P1"),
space("S2", 2, SpaceStatus.OCCUPIED, "T2", "P1"),
space("S3", 3, SpaceStatus.EMPTY, null, null),
space("S4", 4, SpaceStatus.EMPTY, null, null),
space("S5", 5, SpaceStatus.EMPTY, null, null)
), SchedulingPolicy.ft546Defaults());
assertThat(lane.outerSlot()).isPresent();
assertThat(lane.outerSlot().orElseThrow().spaceId()).isEqualTo("S3");
}
@Test
void marksThreeProjectSegmentsCorrupt() {
LaneState lane = analyzer.analyze(List.of(
space("S1", 1, SpaceStatus.OCCUPIED, "T1", "P1"),
space("S2", 2, SpaceStatus.OCCUPIED, "T2", "P2"),
space("S3", 3, SpaceStatus.OCCUPIED, "T3", "P1")
), SchedulingPolicy.ft546Defaults());
assertThat(lane.segmentCount()).isEqualTo(3);
assertThat(lane.corrupt()).isTrue();
}
@Test
void marksAggregatedMultiProjectInventoryCorrupt() {
LaneState lane = analyzer.analyze(List.of(
space("S1", 1, SpaceStatus.OCCUPIED, "T1", "P1", true),
space("S2", 2, SpaceStatus.EMPTY, null, null)
), SchedulingPolicy.ft546Defaults());
assertThat(lane.corrupt()).isTrue();
assertThat(lane.nextSlot()).isEmpty();
}
@Test
void marksEmptyStatusWithTrayCorruptInsteadOfAllocatable() {
LaneState lane = analyzer.analyze(List.of(
space("S1", 1, SpaceStatus.EMPTY, "T1", "P1"),
space("S2", 2, SpaceStatus.EMPTY, null, null)
), SchedulingPolicy.ft546Defaults());
assertThat(lane.corrupt()).isTrue();
assertThat(lane.nextSlot()).isEmpty();
}
@Test
void countsOccupiedInventoryWithUnknownProjectAsASegment() {
LaneState lane = analyzer.analyze(List.of(
space("S1", 1, SpaceStatus.OCCUPIED, "T1", null),
space("S2", 2, SpaceStatus.OCCUPIED, "T2", "P1"),
space("S3", 3, SpaceStatus.EMPTY, null, null)
), SchedulingPolicy.ft546Defaults());
assertThat(lane.segmentCount()).isEqualTo(2);
assertThat(lane.residentProjectNo()).isEmpty();
assertThat(lane.guestProjectNo()).contains("P1");
}
private SpaceState space(String id, int depthOrder, SpaceStatus status, String tray, String project) {
return space(id, depthOrder, status, tray, project, false);
}
private SpaceState space(String id, int depthOrder, SpaceStatus status, String tray, String project,
boolean inventoryProjectConflict) {
return new SpaceState(
id, "01", "L1", depthOrder, 5, 1, 7, depthOrder, "A",
SpaceRole.STORAGE, status, false, inventoryProjectConflict, tray, project, null, null,
null, null, false, null
);
}
}

View File

@@ -0,0 +1,23 @@
package com.ruoyi.algorithm;
import com.ruoyi.algorithm.policy.SchedulingPolicy;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SchedulingPolicyTest {
@Test
void ft546DefaultsMatchTheKnownJavascriptPolicy() {
SchedulingPolicy policy = SchedulingPolicy.ft546Defaults();
assertThat(policy.capacityWeight()).isEqualTo(0.55);
assertThat(policy.efficiencyWeight()).isEqualTo(0.45);
assertThat(policy.warningOccupied()).isEqualTo(564);
assertThat(policy.capacityFirstOccupied()).isEqualTo(597);
assertThat(policy.maxOccupied()).isEqualTo(615);
assertThat(policy.relocationBuffersPerFloor()).isEqualTo(4);
assertThat(policy.outboundCachesPerFloor()).isEqualTo(2);
assertThat(policy.maxTidyCandidates()).isEqualTo(10);
}
}

View File

@@ -0,0 +1,137 @@
package com.ruoyi.algorithm.contract;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.algorithm.inbound.BatchLaneMode;
import com.ruoyi.algorithm.inbound.InboundContext;
import com.ruoyi.algorithm.inbound.InboundPlanner;
import com.ruoyi.algorithm.inbound.InboundPolicy;
import com.ruoyi.algorithm.inbound.InboundRequest;
import com.ruoyi.algorithm.model.SpaceRole;
import com.ruoyi.algorithm.model.SpaceState;
import com.ruoyi.algorithm.model.SpaceStatus;
import com.ruoyi.algorithm.outbound.OutboundCapacityMode;
import com.ruoyi.algorithm.outbound.OutboundContext;
import com.ruoyi.algorithm.outbound.OutboundPlanner;
import com.ruoyi.algorithm.outbound.OutboundRequest;
import com.ruoyi.algorithm.policy.SchedulingPolicy;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class JsAlgorithmContractTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void generatedFixturesMatchTheExactJavascriptSourceAndJavaDecisions() throws Exception {
try (InputStream stream = getClass().getResourceAsStream("/contracts/js-contract-fixtures.json")) {
assertThat(stream).as("run tools/export-js-contracts.js first").isNotNull();
JsonNode root = mapper.readTree(stream);
assertThat(root.path("sourceAlgorithmVersion").asText()).isEqualTo("1.2.0");
Path source = Path.of("..", "..", "..", "ft546-storage-scheduling-algorithm.js");
assertThat(root.path("sourceSha256").asText()).isEqualTo(sha256(Files.readAllBytes(source)));
assertThat(root.path("fixtures")).hasSize(4);
for (JsonNode fixture : root.path("fixtures")) {
verifyFixture(fixture);
}
}
}
private void verifyFixture(JsonNode fixture) {
List<SpaceState> spaces = new ArrayList<>();
Map<String, String> inboundTimes = new LinkedHashMap<>();
for (JsonNode node : fixture.path("spaces")) {
assertThat(node.has("isCacheCore")).isTrue();
assertThat(node.has("isReserveCore")).isTrue();
String expectedRole = node.path("isCacheCore").asBoolean() ? "OUTBOUND_CACHE"
: node.path("isReserveCore").asBoolean() ? "PROTECTED_CORE" : "STORAGE";
assertThat(node.path("role").asText()).isEqualTo(expectedRole);
String inbound = text(node, "inboundAt");
SpaceState space = new SpaceState(text(node, "spaceId"), text(node, "zoneCode"),
text(node, "laneCode"), node.path("depthOrder").asInt(), node.path("laneCapacity").asInt(),
node.path("floorNo").asInt(), node.path("aisleNo").asInt(), node.path("groupCoding").asInt(),
text(node, "rowCode"), SpaceRole.valueOf(node.path("role").asText()),
SpaceStatus.valueOf(node.path("status").asText()), node.path("outboundTaskActive").asBoolean(),
false, text(node, "trayNo"), text(node, "projectNo"), text(node, "productId"),
text(node, "lotNo"), inbound == null ? null : Instant.parse(inbound), null, false, null);
spaces.add(space);
if (inbound != null) inboundTimes.put(space.spaceId(), inbound);
}
SchedulingPolicy scheduling = new SchedulingPolicy(fixture.at("/policy/capacityWeight").asDouble(),
fixture.at("/policy/efficiencyWeight").asDouble(), 564, 597, 615, 4, 2, 10);
JsonNode request = fixture.path("request");
JsonNode expected = fixture.path("expected");
assertThat(expected.path("planStatus").asText()).isEqualTo("READY");
assertThat(expected.path("taskPlanCount").asInt()).isOne();
assertThat(expected.path("plannedCount").asInt()).isOne();
assertThat(expected.path("rejectedCount").asInt()).isZero();
assertThat(expected.path("executionState").asText()).isIn("READY", "READY_WITH_ELEVATOR_TOKEN");
if ("INBOUND".equals(fixture.path("kind").asText())) {
InboundRequest value = new InboundRequest(text(request, "requestId"), text(request, "trayNo"),
text(request, "projectNo"), text(request, "productId"), text(request, "lotNo"),
nullableInt(request, "expectedProjectPallets"), nullableInt(request, "entryFloor"),
nullableInt(request, "targetFloor"), Set.of(), BatchLaneMode.valueOf(request.path("batchLaneMode").asText()));
var plan = new InboundPlanner().plan(value, new InboundContext(spaces,
new InboundPolicy(scheduling, Set.of(), Set.of("COMMON"), List.of(), 0), List.of(),
(int) spaces.stream().filter(SpaceState::occupied).count()));
var assignment = plan.assignment().orElseThrow();
assertThat(assignment.spaceId()).as(fixture.path("id").asText()).isEqualTo(text(expected, "spaceId"));
assertThat(assignment.laneCode()).isEqualTo(text(expected, "laneCode"));
assertThat(assignment.ladderRung().name()).isEqualTo(text(expected, "ladderRung"));
assertThat(assignment.capacityScore()).isEqualTo(expected.path("capacityScore").asDouble());
assertThat(assignment.efficiencyScore()).isEqualTo(expected.path("efficiencyScore").asDouble());
assertThat(assignment.totalScore()).isEqualTo(expected.path("totalScore").asDouble());
assertThat(assignment.openedNewLane()).isEqualTo(expected.path("openedNewLane").asBoolean());
} else {
OutboundRequest value = new OutboundRequest(text(request, "requestId"), text(request, "trayNo"),
text(request, "projectNo"), text(request, "productId"), text(request, "fifoMode"),
nullableInt(request, "fifoToleranceMinutes"), nullableInt(request, "exitFloor"), true);
var plan = new OutboundPlanner().plan(value,
new OutboundContext(spaces, inboundTimes, OutboundCapacityMode.BALANCED));
var assignment = plan.assignment().orElseThrow();
assertThat(plan.status().name()).isEqualTo(expected.path("planStatus").asText());
assertThat(assignment.trayNo()).as(fixture.path("id").asText()).isEqualTo(text(expected, "trayNo"));
assertThat(assignment.sourceSpaceId()).isEqualTo(text(expected, "sourceSpaceId"));
assertThat(assignment.fifo().fifoRank()).isEqualTo(expected.path("fifoRank").asInt());
assertThat(assignment.fifo().fifoViolation()).isEqualTo(expected.path("fifoViolation").asBoolean());
assertThat(assignment.fifo().fifoMode()).isEqualTo(text(expected, "fifoMode"));
assertThat(assignment.fifo().fifoDeviationMinutes())
.isEqualTo(expected.path("fifoDeviationMinutes").asInt());
assertThat(assignment.blockerSpaceIds()).isEqualTo(mapper.convertValue(
expected.path("blockerSpaceIds"), mapper.getTypeFactory().constructCollectionType(List.class, String.class)));
assertThat(assignment.blockingTrayNos()).isEqualTo(mapper.convertValue(
expected.path("blockingTrayNos"), mapper.getTypeFactory().constructCollectionType(List.class, String.class)));
assertThat(assignment.blockerSpaceIds()).hasSize(expected.path("blockerCount").asInt());
assertThat(assignment.capacityScore()).isEqualTo(expected.path("capacityScore").asDouble());
assertThat(assignment.efficiencyScore()).isEqualTo(expected.path("efficiencyScore").asDouble());
assertThat(assignment.totalScore()).isEqualTo(expected.path("totalScore").asDouble());
assertThat(plan.taskCountEstimate().outbound()).isEqualTo(expected.path("taskPlanCount").asInt());
}
}
private String text(JsonNode node, String field) {
JsonNode value = node.get(field);
return value == null || value.isNull() || value.asText().isBlank() ? null : value.asText();
}
private Integer nullableInt(JsonNode node, String field) {
JsonNode value = node.get(field);
return value == null || value.isNull() ? null : value.asInt();
}
private String sha256(byte[] value) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
}
}

View File

@@ -0,0 +1,453 @@
package com.ruoyi.algorithm.inbound;
import com.ruoyi.algorithm.model.SpaceRole;
import com.ruoyi.algorithm.model.SpaceState;
import com.ruoyi.algorithm.model.SpaceStatus;
import com.ruoyi.algorithm.policy.SchedulingPolicy;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class InboundPlannerTest {
private final InboundPlanner planner = new InboundPlanner();
@Test
void continuesFromInsideToOutsideAndProtectsTheWholeLaneFromOutboundWork() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("SAFE", 5, "MAIN", occupied(1, "P1"), occupied(2, "P1"), empty(3), empty(4), empty(5)));
spaces.addAll(lane("BLOCKED", 5, "MAIN", occupied(1, "P1"), outboundEmpty(2), empty(3), empty(4), empty(5)));
InboundPlan plan = plan(request("P1"), spaces);
assertThat(plan.assignment()).isPresent();
assertThat(plan.assignment().orElseThrow().laneCode()).isEqualTo("SAFE");
assertThat(plan.assignment().orElseThrow().spaceId()).isEqualTo("SAFE-3");
assertThat(plan.assignment().orElseThrow().ladderRung()).isEqualTo(InboundLadderRung.CONTINUE);
}
@Test
void rejectsDiscontinuousLaneAndOnlyRejectsAfterEveryLegalSlotIsExhausted() {
List<SpaceState> spaces = lane("HOLE", 5, "MAIN",
occupied(1, "P1"), empty(2), occupied(3, "P1"), empty(4), empty(5));
InboundPlan plan = plan(request("P1"), spaces);
assertThat(plan.rejection()).isPresent();
assertThat(plan.rejection().orElseThrow().code()).isEqualTo(InboundRejectionCode.NO_FEASIBLE_LOCATION);
assertThat(plan.rejection().orElseThrow().reason()).contains("exhausted");
assertThat(plan.rejection().orElseThrow().ladderTrace()).extracting(LadderTraceStep::rung)
.containsExactly(InboundLadderRung.CONTINUE, InboundLadderRung.OPEN_GRADED,
InboundLadderRung.OPEN_ADJACENT, InboundLadderRung.MIXED_GUEST,
InboundLadderRung.POOL_BORROW);
}
@Test
void rejectsIncompleteLaneTopologyInsteadOfTreatingItAsContinuous() {
List<SpaceState> partial = List.of(
space("PARTIAL", 5, "MAIN", 1, 6, "1", occupied(2, "P1")),
space("PARTIAL", 5, "MAIN", 1, 6, "1", empty(3)));
InboundPlan plan = plan(request("P1"), partial);
assertThat(plan.assignment()).isEmpty();
assertThat(plan.rejection().orElseThrow().code()).isEqualTo(InboundRejectionCode.NO_FEASIBLE_LOCATION);
}
@Test
void guestContinuesOnlyInTheOuterSegmentWhileResidentCannotBuryIt() {
List<SpaceState> shared = lane("SHARED", 5, "MAIN",
occupied(1, "HOST"), occupied(2, "HOST"), occupied(3, "GUEST"), empty(4), empty(5));
InboundPlan guest = plan(request("GUEST"), shared);
InboundPlan resident = plan(request("HOST"), shared);
assertThat(guest.assignment()).isPresent();
assertThat(guest.assignment().orElseThrow().spaceId()).isEqualTo("SHARED-4");
assertThat(guest.assignment().orElseThrow().borrowType()).isEqualTo(InboundBorrowType.MIXED_IN);
assertThat(resident.assignment()).isEmpty();
}
@Test
void gradedDepthUsesExpectationThenIncubationFallback() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(emptyLane("D2", 2, "MAIN"));
spaces.addAll(emptyLane("D3", 3, "MAIN"));
spaces.addAll(emptyLane("D5", 5, "MAIN"));
assertThat(plan(request("P", 2), spaces).assignment().orElseThrow().laneCapacity()).isEqualTo(2);
assertThat(plan(request("P", 3), spaces).assignment().orElseThrow().laneCapacity()).isEqualTo(3);
assertThat(plan(request("P", 4), spaces).assignment().orElseThrow().laneCapacity()).isEqualTo(5);
List<SpaceState> onePallet = new ArrayList<>(spaces);
onePallet.add(single("OLD", "MAIN", SpaceStatus.OCCUPIED, "P", "M", "B", false));
assertThat(plan(request("P"), onePallet).assignment().orElseThrow().laneCapacity()).isEqualTo(2);
List<SpaceState> twoPallets = new ArrayList<>(onePallet);
twoPallets.add(single("OLD2", "MAIN", SpaceStatus.OCCUPIED, "P", "M", "B", false));
assertThat(plan(request("P"), twoPallets).assignment().orElseThrow().laneCapacity()).isEqualTo(5);
}
@Test
void smallProjectPrefersSinglePoolBeforeAdjacentDepth() {
List<SpaceState> spaces = new ArrayList<>();
spaces.add(single("SINGLE", "FLEX", SpaceStatus.EMPTY, null, null, null, false));
spaces.addAll(emptyLane("D3", 3, "MAIN"));
InboundAssignment assignment = plan(request("SMALL", 2), spaces).assignment().orElseThrow();
assertThat(assignment.spaceId()).isEqualTo("SINGLE");
assertThat(assignment.ladderRung()).isEqualTo(InboundLadderRung.POOL_BORROW);
}
@Test
void adjacentDepthOrderIsDeterministicForEachTargetGrade() {
assertThat(plan(request("P2", 2), lanesOfDepths(3, 5)).assignment().orElseThrow().laneCapacity()).isEqualTo(3);
assertThat(plan(request("P3", 3), lanesOfDepths(2, 5)).assignment().orElseThrow().laneCapacity()).isEqualTo(5);
assertThat(plan(request("P5", 5), lanesOfDepths(2, 3)).assignment().orElseThrow().laneCapacity()).isEqualTo(3);
}
@Test
void mixedGuestAllowsOnlyOneGuestRejectsIsolationBothWaysAndExcludesPendingResidents() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("PENDING", 5, "MAIN", occupied(1, "A"), occupied(2, "A"), occupied(3, "A"), occupied(4, "A"), empty(5)));
spaces.addAll(lane("OPEN", 5, "MAIN", occupied(1, "B"), occupied(2, "B"), occupied(3, "B"), occupied(4, "B"), empty(5)));
InboundContext context = context(spaces, policy(Set.of("ISO"), Set.of("COMMON"), List.of(), 0),
List.of(new PendingOutbound("A", null, null)));
assertThat(planner.plan(request("C"), context).assignment().orElseThrow().laneCode()).isEqualTo("OPEN");
List<SpaceState> withGuest = lane("TWO", 5, "MAIN", occupied(1, "A"), occupied(2, "G"), empty(3), empty(4), empty(5));
assertThat(plan(request("THIRD"), withGuest).assignment()).isEmpty();
assertThat(planner.plan(request("ISO"), context).assignment()).isEmpty();
List<SpaceState> isolatedResident = lane("ISO-LANE", 5, "MAIN",
occupied(1, "ISO"), occupied(2, "ISO"), occupied(3, "ISO"), occupied(4, "ISO"), empty(5));
assertThat(planner.plan(request("NORMAL"), context(isolatedResident,
policy(Set.of("ISO"), Set.of("COMMON"), List.of(), 0), List.of())).assignment()).isEmpty();
}
@Test
void mixedGuestRequiresBothProjectsToAllowMixing() {
List<SpaceState> residentLane = lane("MIX-POLICY", 5, "MAIN",
occupied(1, "RESIDENT"), occupied(2, "RESIDENT"), occupied(3, "RESIDENT"),
occupied(4, "RESIDENT"), empty(5));
InboundPolicy deniedIncoming = new InboundPolicy(SchedulingPolicy.ft546Defaults(), Set.of(),
Set.of("COMMON"), List.of(), 0, Set.of("INCOMING"));
InboundPolicy deniedResident = new InboundPolicy(SchedulingPolicy.ft546Defaults(), Set.of(),
Set.of("COMMON"), List.of(), 0, Set.of("RESIDENT"));
assertThat(planner.plan(request("INCOMING"), context(residentLane, deniedIncoming, List.of())).assignment())
.isEmpty();
assertThat(planner.plan(request("INCOMING"), context(residentLane, deniedResident, List.of())).assignment())
.isEmpty();
assertThat(planner.plan(request("INCOMING"), context(residentLane, InboundPolicy.defaults(), List.of()))
.assignment()).isPresent();
}
@Test
void pendingOutboundBlocksOnlyExactTrayOrResolvedLaneAndUnresolvedScopeIsSafe() {
List<SpaceState> lane = lane("PENDING-MATCH", 5, "MAIN",
occupied(1, "P"), empty(2), empty(3), empty(4), empty(5));
InboundPolicy policy = InboundPolicy.defaults();
assertThat(planner.plan(request("P"), context(lane, policy,
List.of(new PendingOutbound("OTHER", "OTHER", "PENDING-MATCH-T1")))).assignment()).isEmpty();
assertThat(planner.plan(request("P"), context(lane, policy,
List.of(new PendingOutbound("P", "M", null)))).assignment()).isPresent();
assertThat(planner.plan(request("P"), context(lane, policy,
List.of(new PendingOutbound("P", null, null)))).assignment()).isPresent();
assertThat(planner.plan(request("P"), context(lane, policy,
List.of(new PendingOutbound("OTHER", null, null, "PENDING-MATCH", "EXPLICIT"))))
.assignment()).isEmpty();
assertThat(planner.plan(request("P"), context(lane, policy,
List.of(new PendingOutbound("OTHER", null, "NOT-THE-TRAY", "PENDING-MATCH", "EXPLICIT"))))
.assignment()).isEmpty();
assertThat(planner.plan(request("P"), context(lane, policy,
List.of(new PendingOutbound("P", "OTHER", null)))).assignment()).isPresent();
assertThat(planner.plan(request("P"), context(lane, policy,
List.of(new PendingOutbound("P", "M", "NOT-THE-TRAY")))).assignment()).isPresent();
}
@Test
void continuationNeverSkipsAnUnavailablePhysicalNextDepth() {
List<SpaceState> reservedLane = lane("RESERVED-NEXT", 5, "MAIN",
occupied(1, "P"), empty(2), empty(3), empty(4), empty(5));
reservedLane.set(1, withReserved(reservedLane.get(1)));
List<SpaceState> roleLane = lane("ROLE-NEXT", 5, "MAIN",
occupied(1, "P"), empty(2), empty(3), empty(4), empty(5));
roleLane.set(1, withRole(roleLane.get(1), SpaceRole.RELOCATION_BUFFER));
List<SpaceState> disabledLane = lane("DISABLED-NEXT", 5, "MAIN",
occupied(1, "P"), empty(2), empty(3), empty(4), empty(5));
disabledLane.set(1, withStatus(disabledLane.get(1), SpaceStatus.DISABLED));
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(reservedLane);
spaces.addAll(roleLane);
spaces.addAll(disabledLane);
assertThat(plan(request("P"), spaces).assignment()).isEmpty();
}
@Test
void poolWatermarkAndFlexCacheReserveTiersAreEnforced() {
List<SpaceState> tiers = List.of(
single("FLEX", "FLEX", SpaceStatus.EMPTY, null, null, null, false),
single("CACHE", "CACHE", SpaceStatus.EMPTY, null, null, null, false),
single("RESERVE", "RESERVE", SpaceStatus.EMPTY, null, null, null, false));
assertThat(plan(request("P", 5), tiers).assignment().orElseThrow().spaceId()).isEqualTo("FLEX");
List<SpaceState> noFlex = tiers.stream().filter(space -> !space.spaceId().equals("FLEX")).toList();
assertThat(plan(request("P", 5), noFlex).assignment().orElseThrow().borrowType()).isEqualTo(InboundBorrowType.CACHE_BORROWED);
InboundContext belowHighWater = context(List.of(tiers.get(2)), policy(Set.of(), Set.of("COMMON"), List.of(), 0), List.of());
assertThat(planner.plan(request("P", 5), belowHighWater).assignment()).isEmpty();
InboundContext atHighWater = new InboundContext(List.of(tiers.get(2)),
policy(Set.of(), Set.of("COMMON"), List.of(), 0), List.of(), 597);
assertThat(planner.plan(request("P", 5), atHighWater).assignment().orElseThrow().borrowType())
.isEqualTo(InboundBorrowType.RESERVE_BORROWED);
InboundContext guarded = context(List.of(tiers.get(0)), policy(Set.of(), Set.of("COMMON"), List.of(), 1), List.of());
assertThat(planner.plan(request("P", 5), guarded).rejection().orElseThrow().ladderTrace())
.anySatisfy(step -> assertThat(step.reason()).contains("watermark"));
}
@Test
void zeroPoolReserveWithoutSingleSlotsReportsAvailabilityInsteadOfWatermark() {
InboundPlan plan = planner.plan(request("P", 5),
context(List.of(), policy(Set.of(), Set.of("COMMON"), List.of(), 0), List.of()));
assertThat(plan.rejection().orElseThrow().ladderTrace())
.filteredOn(step -> step.rung() == InboundLadderRung.POOL_BORROW)
.singleElement()
.satisfies(step -> {
assertThat(step.reason()).contains("No legal");
assertThat(step.reason()).doesNotContain("watermark");
});
}
@Test
void commonPartsUseSingleTiersThenSameMaterialDoubleLane() {
List<SpaceState> spaces = new ArrayList<>();
spaces.add(single("CACHE", "CACHE", SpaceStatus.EMPTY, null, null, null, false));
spaces.addAll(laneWithProduct("DOUBLE", 2, "MAIN", "COMMON", "M1", null, empty(2)));
InboundPolicy commonPolicy = policy(Set.of(), Set.of("COMMON"), List.of(), 0);
assertThat(planner.plan(request("COMMON", "M1", "B", null, BatchLaneMode.OPEN),
context(spaces, commonPolicy, List.of())).assignment().orElseThrow().spaceId()).isEqualTo("CACHE");
List<SpaceState> doublesOnly = spaces.stream().filter(space -> !space.spaceId().equals("CACHE")).toList();
assertThat(planner.plan(request("COMMON", "M1", "B", null, BatchLaneMode.OPEN),
context(doublesOnly, commonPolicy, List.of())).assignment().orElseThrow().spaceId()).isEqualTo("DOUBLE-2");
assertThat(planner.plan(request("COMMON", "OTHER", "B", null, BatchLaneMode.OPEN),
context(doublesOnly, commonPolicy, List.of())).assignment()).isEmpty();
}
@Test
void manualReservationsRestrictLaneOwnership() {
List<SpaceState> lane = emptyLane("VIP", 5, "MAIN");
List<ManualLaneReservation> named = List.of(new ManualLaneReservation("VIP", "P-VIP"));
InboundPolicy namedPolicy = policy(Set.of(), Set.of("COMMON"), named, 0);
assertThat(planner.plan(request("OTHER", 5), context(lane, namedPolicy, List.of())).assignment()).isEmpty();
assertThat(planner.plan(request("P-VIP", 5), context(lane, namedPolicy, List.of())).assignment()).isPresent();
InboundPolicy isolatedOnly = policy(Set.of("ISO"), Set.of("COMMON"),
List.of(new ManualLaneReservation("VIP", null)), 0);
assertThat(planner.plan(request("ISO", 5), context(lane, isolatedOnly, List.of())).assignment()).isPresent();
assertThat(planner.plan(request("OTHER", 5), context(lane, isolatedOnly, List.of())).assignment()).isEmpty();
}
@Test
void rejectsUnknownManualReservationLane() {
InboundPolicy policy = policy(Set.of(), Set.of("COMMON"),
List.of(new ManualLaneReservation("MISSING", "P")), 0);
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
planner.plan(request("P", 5), context(emptyLane("REAL", 5, "MAIN"), policy, List.of())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("MISSING");
}
@Test
void protectedReservedAndWrongFloorSlotsStayInvisibleAcrossTheLadder() {
SpaceState reserved = withReserved(single("RESERVED", "FLEX", SpaceStatus.EMPTY,
null, null, null, false));
SpaceState relocation = withRole(single("RELOC", "FLEX", SpaceStatus.EMPTY,
null, null, null, false), SpaceRole.RELOCATION_BUFFER);
List<SpaceState> spaces = new ArrayList<>(List.of(reserved, relocation));
spaces.addAll(emptyLane("FLOOR-2", 5, "MAIN", 2, 6, "1"));
spaces.addAll(emptyLane("FLOOR-3", 5, "MAIN", 3, 6, "1"));
InboundRequest floorTwo = new InboundRequest("REQ", "TRAY", "P", "M", "B",
5, 1, null, Set.of(2), BatchLaneMode.OPEN);
assertThat(plan(floorTwo, spaces).assignment().orElseThrow().laneCode()).isEqualTo("FLOOR-2");
}
@Test
void sealedModeContinuesOnlyExactCohortOrOpensCompletelyEmptyLane() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(laneWithProduct("WRONG", 5, "MAIN", "P", "M", "OLD", empty(2), empty(3), empty(4), empty(5)));
spaces.addAll(laneWithProduct("EXACT", 5, "MAIN", "P", "M", "B1", empty(2), empty(3), empty(4), empty(5)));
InboundAssignment exact = plan(request("P", "M", "B1", 5, BatchLaneMode.SEALED), spaces).assignment().orElseThrow();
assertThat(exact.laneCode()).isEqualTo("EXACT");
assertThat(exact.openedNewLane()).isFalse();
InboundPlan rejected = plan(request("P", "M", "B2", 5, BatchLaneMode.SEALED),
spaces.stream().filter(space -> space.laneCode().equals("WRONG")).toList());
assertThat(rejected.rejection().orElseThrow().code()).isEqualTo(InboundRejectionCode.NO_BATCH_COMPATIBLE_LANE);
assertThat(rejected.rejection().orElseThrow().reason()).contains("same project, product and lot");
assertThat(rejected.rejection().orElseThrow().ladderTrace()).isNotEmpty();
}
@Test
void commonSealedDoubleLaneIgnoresLotAndUsesNormalNoFeasibleRejection() {
List<SpaceState> compatible = laneWithProduct("COMMON-DOUBLE", 2, "MAIN",
"COMMON", "M1", "OLD-LOT", empty(2));
InboundRequest sealedNewLot = request("COMMON", "M1", "NEW-LOT", null, BatchLaneMode.SEALED);
InboundPlan continued = plan(sealedNewLot, compatible);
assertThat(continued.assignment()).isPresent();
assertThat(continued.assignment().orElseThrow().ladderRung())
.isEqualTo(InboundLadderRung.COMMON_LANE_CONTINUE);
List<SpaceState> incompatible = laneWithProduct("COMMON-OTHER", 2, "MAIN",
"COMMON", "OTHER", "OLD-LOT", empty(2));
InboundPlan rejected = plan(sealedNewLot, incompatible);
assertThat(rejected.rejection().orElseThrow().code())
.isEqualTo(InboundRejectionCode.NO_FEASIBLE_LOCATION);
}
@Test
void scoringAndTieBreakMatchJavascriptFormula() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(emptyLane("B", 2, "MAIN", 1, 6, "1"));
spaces.addAll(emptyLane("A", 2, "MAIN", 1, 6, "1"));
InboundAssignment assignment = plan(request("P", 2), spaces).assignment().orElseThrow();
assertThat(assignment.laneCode()).isEqualTo("A");
assertThat(assignment.capacityScore()).isEqualTo(43.0);
assertThat(assignment.efficiencyScore()).isEqualTo(100.0);
assertThat(assignment.totalScore()).isEqualTo(68.7);
}
private InboundPlan plan(InboundRequest request, List<SpaceState> spaces) {
return planner.plan(request, context(spaces, InboundPolicy.defaults(), List.of()));
}
private InboundRequest request(String project) {
return request(project, "M", "B", null, BatchLaneMode.OPEN);
}
private InboundRequest request(String project, Integer expected) {
return request(project, "M", "B", expected, BatchLaneMode.OPEN);
}
private InboundRequest request(String project, String product, String lot, Integer expected, BatchLaneMode mode) {
return new InboundRequest("REQ", "TRAY", project, product, lot, expected, 1, null, Set.of(), mode);
}
private InboundContext context(List<SpaceState> spaces, InboundPolicy policy, List<PendingOutbound> pending) {
return new InboundContext(spaces, policy, pending, (int) spaces.stream().filter(SpaceState::occupied).count());
}
private InboundPolicy policy(Set<String> isolated, Set<String> common,
List<ManualLaneReservation> reservations, int reserve) {
return new InboundPolicy(SchedulingPolicy.ft546Defaults(), isolated, common, reservations, reserve);
}
private List<SpaceState> lanesOfDepths(int... depths) {
List<SpaceState> spaces = new ArrayList<>();
for (int depth : depths) spaces.addAll(emptyLane("D" + depth, depth, "MAIN"));
return spaces;
}
private List<SpaceState> emptyLane(String code, int depth, String zone) {
return emptyLane(code, depth, zone, 1, 6, "1");
}
private List<SpaceState> emptyLane(String code, int depth, String zone, int floor, int aisle, String row) {
List<SpaceState> spaces = new ArrayList<>();
for (int i = 1; i <= depth; i++) {
spaces.add(space(code, depth, zone, floor, aisle, row, empty(i)));
}
return spaces;
}
private List<SpaceState> lane(String code, int depth, String zone, Slot... slots) {
List<SpaceState> spaces = new ArrayList<>();
for (Slot slot : slots) spaces.add(space(code, depth, zone, 1, 6, "1", slot));
return spaces;
}
private List<SpaceState> laneWithProduct(String code, int depth, String zone, String project,
String product, String lot, Slot... remainder) {
List<Slot> slots = new ArrayList<>();
slots.add(new Slot(1, SpaceStatus.OCCUPIED, project, product, lot, false));
slots.addAll(List.of(remainder));
return lane(code, depth, zone, slots.toArray(Slot[]::new));
}
private SpaceState single(String id, String zone, SpaceStatus status, String project,
String product, String lot, boolean outbound) {
return new SpaceState(id, zone, id, 1, 1, 1, 6, 1, "1", SpaceRole.STORAGE,
status, outbound, false, status == SpaceStatus.OCCUPIED ? "T-" + id : null,
project, product, lot, Instant.parse("2026-07-01T00:00:00Z"), null, false, null);
}
private SpaceState withReserved(SpaceState source) {
return new SpaceState(source.spaceId(), source.zoneCode(), source.laneCode(), source.depthOrder(),
source.laneCapacity(), source.floorNo(), source.aisleNo(), source.groupCoding(), source.rowCode(),
source.role(), source.status(), source.outboundTaskActive(), source.inventoryProjectConflict(),
source.trayNo(), source.projectNo(), source.productId(), source.lotNo(), source.inboundAt(),
source.outboundAt(), true, source.rowVersion());
}
private SpaceState withRole(SpaceState source, SpaceRole role) {
return new SpaceState(source.spaceId(), source.zoneCode(), source.laneCode(), source.depthOrder(),
source.laneCapacity(), source.floorNo(), source.aisleNo(), source.groupCoding(), source.rowCode(),
role, source.status(), source.outboundTaskActive(), source.inventoryProjectConflict(),
source.trayNo(), source.projectNo(), source.productId(), source.lotNo(), source.inboundAt(),
source.outboundAt(), source.reserved(), source.rowVersion());
}
private SpaceState withStatus(SpaceState source, SpaceStatus status) {
return new SpaceState(source.spaceId(), source.zoneCode(), source.laneCode(), source.depthOrder(),
source.laneCapacity(), source.floorNo(), source.aisleNo(), source.groupCoding(), source.rowCode(),
source.role(), status, source.outboundTaskActive(), source.inventoryProjectConflict(),
null, source.projectNo(), source.productId(), source.lotNo(), source.inboundAt(),
source.outboundAt(), source.reserved(), source.rowVersion());
}
private SpaceState space(String lane, int depth, String zone, int floor, int aisle, String row, Slot slot) {
return new SpaceState(lane + "-" + slot.depth, zone, lane, slot.depth, depth, floor, aisle,
slot.depth, row, SpaceRole.STORAGE, slot.status, slot.outbound, false,
slot.status == SpaceStatus.OCCUPIED ? lane + "-T" + slot.depth : null,
slot.project, slot.product, slot.lot, Instant.parse("2026-07-01T00:00:00Z"),
null, false, null);
}
private Slot occupied(int depth, String project) {
return new Slot(depth, SpaceStatus.OCCUPIED, project, "M", "B", false);
}
private Slot empty(int depth) {
return new Slot(depth, SpaceStatus.EMPTY, null, null, null, false);
}
private Slot outboundEmpty(int depth) {
return new Slot(depth, SpaceStatus.EMPTY, null, null, null, true);
}
private record Slot(int depth, SpaceStatus status, String project, String product, String lot,
boolean outbound) {
}
}

View File

@@ -0,0 +1,55 @@
package com.ruoyi.algorithm.integration;
import com.ruoyi.algorithm.repository.JdbcWarehouseRepository;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import javax.sql.DataSource;
import java.lang.reflect.Field;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
class AlgorithmConfigurationTest {
@Test
void repositoryUsesTheWmsTransactionManagerBoundToItsDataSource() throws Exception {
DataSource dataSource = mock(DataSource.class);
DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
Object repository = new AlgorithmConfiguration().algorithmWarehouseRepository(
dataSource, new AlgorithmProperties(), transactionManager);
Field field = JdbcWarehouseRepository.class.getDeclaredField("transactions");
field.setAccessible(true);
TransactionTemplate transactions = (TransactionTemplate) field.get(repository);
assertThat(transactions.getTransactionManager()).isSameAs(transactionManager);
}
@Test
void repositoryRejectsATransactionManagerBoundToAnotherDataSource() {
DataSource algorithmDataSource = mock(DataSource.class);
DataSource otherDataSource = mock(DataSource.class);
DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(otherDataSource);
assertThatThrownBy(() -> new AlgorithmConfiguration().algorithmWarehouseRepository(
algorithmDataSource, new AlgorithmProperties(), transactionManager))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("same DataSource");
}
@Test
void tidyRuntimeDefaultsAreConservativeAndReplayIsDisabled() {
AlgorithmProperties properties = new AlgorithmProperties();
assertThat(properties.getTidyAutoScanMs()).isEqualTo(600_000L);
assertThat(properties.getTidyReconcileMs()).isEqualTo(5_000L);
assertThat(properties.getTidyClaimLeaseSeconds()).isEqualTo(60);
assertThat(properties.getTidyManualMaxConcurrency()).isEqualTo(3);
assertThat(properties.getTidyWcsSnapshotUrl()).contains("tidyVehicleSnapshot");
assertThat(properties.getReplayRunId()).isBlank();
assertThat(properties.getReplayDatabase()).isBlank();
}
}

View File

@@ -0,0 +1,46 @@
package com.ruoyi.algorithm.integration;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SchedulingRoutingPolicyTest {
@Test
void legacySkipsAlgorithmAndUsesStoredProcedure() {
SchedulingRoutingPolicy policy = new SchedulingRoutingPolicy(SchedulingMode.LEGACY, false);
assertThat(policy.runAlgorithm()).isFalse();
assertThat(policy.runLegacy(SchedulingDecisionStatus.READY)).isTrue();
assertThat(policy.algorithmOwnsDecision()).isFalse();
}
@Test
void shadowCalculatesAlgorithmButKeepsLegacyBusinessWrites() {
SchedulingRoutingPolicy policy = new SchedulingRoutingPolicy(SchedulingMode.SHADOW, false);
assertThat(policy.runAlgorithm()).isTrue();
assertThat(policy.runLegacy(SchedulingDecisionStatus.READY)).isTrue();
assertThat(policy.runLegacy(SchedulingDecisionStatus.WAITING)).isTrue();
assertThat(policy.algorithmOwnsDecision()).isFalse();
}
@Test
void activeNeverFallsBackForWaitingOrBusinessRejection() {
SchedulingRoutingPolicy policy = new SchedulingRoutingPolicy(SchedulingMode.ACTIVE, true);
assertThat(policy.runAlgorithm()).isTrue();
assertThat(policy.runLegacy(SchedulingDecisionStatus.READY)).isFalse();
assertThat(policy.runLegacy(SchedulingDecisionStatus.WAITING)).isFalse();
assertThat(policy.runLegacy(SchedulingDecisionStatus.REJECTED)).isFalse();
assertThat(policy.algorithmOwnsDecision()).isTrue();
}
@Test
void activeTechnicalFallbackRequiresExplicitOptIn() {
assertThat(new SchedulingRoutingPolicy(SchedulingMode.ACTIVE, false)
.runLegacy(SchedulingDecisionStatus.TECHNICAL_ERROR)).isFalse();
assertThat(new SchedulingRoutingPolicy(SchedulingMode.ACTIVE, true)
.runLegacy(SchedulingDecisionStatus.TECHNICAL_ERROR)).isTrue();
}
}

View File

@@ -0,0 +1,266 @@
package com.ruoyi.algorithm.outbound;
import com.ruoyi.algorithm.model.SpaceRole;
import com.ruoyi.algorithm.model.SpaceState;
import com.ruoyi.algorithm.model.SpaceStatus;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class OutboundPlannerTest {
private final OutboundPlanner planner = new OutboundPlanner();
@Test
void projectIsRequiredWithoutTrayProductIsOptionalAndWholeTrayDefaultsTrue() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("M1", 1, 1, stock(1, "T-M1", "P", "M1", "2026-07-01T00:00:00Z")));
spaces.addAll(lane("M2", 1, 1, stock(1, "T-M2", "P", "M2", "2026-07-02T00:00:00Z")));
OutboundPlan anyProduct = plan(request(null, "P", null, "OFF", null, null), spaces);
OutboundPlan filtered = plan(request(null, "P", "M2", "OFF", null, null), spaces);
OutboundPlan missingProject = plan(request(null, null, "M1", "OFF", null, null), spaces);
assertThat(anyProduct.assignment().orElseThrow().trayNo()).isEqualTo("T-M1");
assertThat(anyProduct.assignment().orElseThrow().wholeTray()).isTrue();
assertThat(filtered.assignment().orElseThrow().trayNo()).isEqualTo("T-M2");
assertThat(missingProject.error().orElseThrow().code()).isEqualTo(OutboundErrorCode.OUTBOUND_SCOPE_REQUIRED);
}
@Test
void specifiedTrayIsNeverReplacedAndReportsItsRealFifoRankAndWarning() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("OLD", 1, 1, stock(1, "T-OLD", "P", "M", "2026-07-01T00:00:00Z")));
spaces.addAll(lane("NEW", 1, 1, stock(1, "T-NEW", "P", "M", "2026-07-02T00:00:00Z")));
OutboundPlan result = plan(request("T-NEW", null, null, "STRICT", 0, true), spaces);
assertThat(result.assignment().orElseThrow().trayNo()).isEqualTo("T-NEW");
assertThat(result.assignment().orElseThrow().fifo().fifoRank()).isEqualTo(2);
assertThat(result.assignment().orElseThrow().fifo().fifoViolation()).isTrue();
assertThat(result.assignment().orElseThrow().fifo().warning().orElseThrow().code())
.isEqualTo("FIFO_OVERRIDDEN_BY_TRAY");
}
@Test
void fifoOffUsesActualBlockersThenCapacityModeLaneReleaseFloorAndSpaceId() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("DEEP", 5, 1,
stock(1, "T-DEEP", "P", "M", "2026-07-01T00:00:00Z"),
stock(2, "BLOCK", "X", "X", "2026-07-01T00:00:00Z"), empty(3), empty(4), empty(5)));
spaces.addAll(lane("A-SOLE", 1, 3, stock(1, "T-SOLE", "P", "M", "2026-07-03T00:00:00Z")));
spaces.addAll(lane("B-NOT-SOLE", 2, 1,
stock(1, "OTHER", "X", "X", "2026-07-02T00:00:00Z"),
stock(2, "T-NEAR", "P", "M", "2026-07-02T00:00:00Z")));
OutboundPlan balanced = planner.plan(request(null, "P", "M", "OFF", null, true),
context(spaces, OutboundCapacityMode.BALANCED, Map.of()));
OutboundPlan warning = planner.plan(request(null, "P", "M", "OFF", null, true),
context(spaces, OutboundCapacityMode.WARNING, Map.of()));
assertThat(balanced.assignment().orElseThrow().trayNo()).isEqualTo("T-NEAR");
assertThat(warning.assignment().orElseThrow().trayNo()).isEqualTo("T-SOLE");
assertThat(warning.assignment().orElseThrow().blockerSpaceIds()).isEmpty();
assertThat(warning.assignment().orElseThrow().laneReleasing()).isTrue();
}
@Test
void strictFifoChoosesTheGlobalOldestEvenWhenItIsDeep() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("DEEP", 2, 2,
stock(1, "T-OLD", "P", "M", "2026-07-01T00:00:00Z"),
stock(2, "BLOCK", "X", "X", "2026-07-01T01:00:00Z")));
spaces.addAll(lane("DIRECT", 1, 1,
stock(1, "T-NEW", "P", "M", "2026-07-03T00:00:00Z")));
spaces.addAll(buffers(2, 1));
OutboundAssignment assignment = plan(request(null, "P", "M", "STRICT", 0, true), spaces)
.assignment().orElseThrow();
assertThat(assignment.trayNo()).isEqualTo("T-OLD");
assertThat(assignment.blockerSpaceIds()).containsExactly("DEEP-2");
assertThat(assignment.fifo().fifoRank()).isEqualTo(1);
}
@Test
void flexibleFifoOptimizesOnlyInsideTheOldestToleranceWindow() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("OLD", 2, 1,
stock(1, "T-OLD", "P", "M", "2026-07-01T00:00:00Z"),
stock(2, "BLOCK", "X", "X", "2026-07-01T01:00:00Z")));
spaces.addAll(lane("WINDOW", 1, 2,
stock(1, "T-WINDOW", "P", "M", "2026-07-01T00:30:00Z")));
spaces.addAll(lane("LATE", 1, 1,
stock(1, "T-LATE", "P", "M", "2026-07-02T00:00:00Z")));
spaces.addAll(buffers(1, 1));
OutboundAssignment assignment = plan(request(null, "P", "M", "FLEXIBLE", 60, true), spaces)
.assignment().orElseThrow();
assertThat(assignment.trayNo()).isEqualTo("T-WINDOW");
assertThat(assignment.fifo().fifoDeviationMinutes()).isEqualTo(30);
}
@Test
void missingAndInvalidFifoTimesReturnStructuredErrors() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("VALID", 1, 1, stock(1, "T-VALID", "P", "M", "2026-07-01T00:00:00Z")));
spaces.addAll(lane("BAD", 1, 1, stock(1, "T-BAD", "P", "M", null)));
OutboundPlan missing = planner.plan(request(null, "P", "M", "STRICT", 0, true),
context(spaces, OutboundCapacityMode.BALANCED, Map.of()));
OutboundPlan invalid = planner.plan(request(null, "P", "M", "STRICT", 0, true),
context(spaces, OutboundCapacityMode.BALANCED, Map.of("BAD-1", "not-a-time")));
assertThat(missing.error().orElseThrow().code()).isEqualTo(OutboundErrorCode.FIFO_DATA_MISSING);
assertThat(missing.error().orElseThrow().details()).containsEntry("spaceIds", List.of("BAD-1"));
assertThat(invalid.error().orElseThrow().code()).isEqualTo(OutboundErrorCode.FIFO_INVALID_TIME);
assertThat(invalid.error().orElseThrow().details()).containsEntry("spaceId", "BAD-1");
}
@Test
void insufficientSameFloorRelocationBuffersAlwaysWaitAndCountNoCompletedTasks() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("DEEP", 3, 4,
stock(1, "TARGET", "P", "M", "2026-07-01T00:00:00Z"),
stock(2, "B2", "X", "X", "2026-07-01T00:00:00Z"),
stock(3, "B3", "X", "X", "2026-07-01T00:00:00Z")));
spaces.addAll(buffers(4, 1));
OutboundPlan plan = plan(request("TARGET", null, null, "OFF", null, true), spaces);
assertThat(plan.status()).isEqualTo(OutboundPlanStatus.WAITING);
assertThat(plan.waiting().orElseThrow().code()).isEqualTo(OutboundWaitingCode.WAITING_RELOCATION_BUFFER);
assertThat(plan.waiting().orElseThrow().required()).isEqualTo(2);
assertThat(plan.waiting().orElseThrow().available()).isEqualTo(1);
assertThat(plan.waiting().orElseThrow().reason()).contains("same-floor relocation buffer");
assertThat(plan.waiting().orElseThrow().retryable()).isTrue();
assertThat(plan.taskCountEstimate()).isEqualTo(TaskCountEstimate.zero());
assertThat(plan.error()).isEmpty();
}
@Test
void anActiveSameFloorDeepWorkflowSerializesTheNextOneEvenWhenBuffersRemain() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("DEEP", 2, 4,
stock(1, "TARGET", "P", "M", "2026-07-01T00:00:00Z"),
stock(2, "BLOCK", "X", "X", "2026-07-01T00:00:00Z")));
spaces.addAll(buffers(4, 4));
OutboundPlan plan = planner.plan(request("TARGET", null, null, "OFF", null, true),
new OutboundContext(spaces, rawTimes(spaces), OutboundCapacityMode.BALANCED, Set.of(4)));
assertThat(plan.status()).isEqualTo(OutboundPlanStatus.WAITING);
assertThat(plan.waiting().orElseThrow().code()).isEqualTo(OutboundWaitingCode.WAITING_RELOCATION_BUFFER);
assertThat(plan.waiting().orElseThrow().reason()).contains("active same-floor deep workflow");
assertThat(plan.waiting().orElseThrow().required()).isEqualTo(1);
assertThat(plan.waiting().orElseThrow().available()).isEqualTo(4);
assertThat(plan.taskCountEstimate()).isEqualTo(TaskCountEstimate.zero());
}
@Test
void deepOutboundBuildsSequentialMoveOutOutboundRestoreWorkflowAndWholeWorkflowLocks() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("DEEP", 3, 2,
stock(1, "TARGET", "P", "M", "2026-07-01T00:00:00Z"),
stock(2, "B2", "X", "X", "2026-07-01T00:00:00Z"),
stock(3, "B3", "X", "X", "2026-07-01T00:00:00Z")));
spaces.addAll(buffers(2, 2));
OutboundPlan plan = plan(request("TARGET", null, null, "OFF", null, true), spaces);
OutboundWorkflow workflow = plan.assignment().orElseThrow().workflow().orElseThrow();
assertThat(plan.status()).isEqualTo(OutboundPlanStatus.READY);
assertThat(plan.assignment().orElseThrow().blockerSpaceIds()).containsExactly("DEEP-3", "DEEP-2");
assertThat(workflow.steps()).extracting(OutboundWorkflowStep::phase)
.containsExactly(OutboundWorkflowPhase.MOVE_OUT, OutboundWorkflowPhase.MOVE_OUT,
OutboundWorkflowPhase.OUTBOUND, OutboundWorkflowPhase.RESTORE,
OutboundWorkflowPhase.RESTORE);
assertThat(workflow.steps()).extracting(OutboundWorkflowStep::dependsOnAssignmentId)
.containsExactly(null, "REQ-MOVE-OUT-01", "REQ-MOVE-OUT-02", "REQ-OUTBOUND-01", "REQ-RESTORE-01");
assertThat(workflow.steps().get(0).sourceSpaceId()).isEqualTo("DEEP-3");
assertThat(workflow.steps().get(3).targetSpaceId()).isEqualTo("DEEP-1");
assertThat(workflow.steps().get(4).targetSpaceId()).isEqualTo("DEEP-2");
assertThat(workflow.lockedLaneSpaceIds()).containsExactly("DEEP-1", "DEEP-2", "DEEP-3");
assertThat(workflow.lockedBufferSpaceIds()).containsExactly("BUFFER-2-1", "BUFFER-2-2");
assertThat(workflow.lockReleaseRule()).isEqualTo("RELEASE_AFTER_FINAL_RESTORE");
assertThat(plan.taskCountEstimate()).isEqualTo(new TaskCountEstimate(0, 1, 4, 1));
}
@Test
void stockNotFoundIsDifferentFromFifoErrorsAndWaiting() {
OutboundPlan plan = plan(request("MISSING", null, null, "STRICT", 0, true), List.of());
assertThat(plan.status()).isEqualTo(OutboundPlanStatus.ERROR);
assertThat(plan.error().orElseThrow().code()).isEqualTo(OutboundErrorCode.STOCK_NOT_FOUND);
assertThat(plan.waiting()).isEmpty();
}
private OutboundPlan plan(OutboundRequest request, List<SpaceState> spaces) {
return planner.plan(request, context(spaces, OutboundCapacityMode.BALANCED, Map.of()));
}
private OutboundContext context(List<SpaceState> spaces, OutboundCapacityMode mode,
Map<String, String> rawTimeOverrides) {
Map<String, String> raw = rawTimes(spaces);
raw.putAll(rawTimeOverrides);
return new OutboundContext(spaces, raw, mode);
}
private Map<String, String> rawTimes(List<SpaceState> spaces) {
Map<String, String> raw = new HashMap<>();
for (SpaceState space : spaces) {
if (space.inboundAt() != null) raw.put(space.spaceId(), space.inboundAt().toString());
}
return raw;
}
private OutboundRequest request(String tray, String project, String product, String fifoMode,
Integer tolerance, Boolean wholeTray) {
return new OutboundRequest("REQ", tray, project, product, fifoMode, tolerance, 1, wholeTray);
}
private List<SpaceState> buffers(int floor, int count) {
List<SpaceState> buffers = new ArrayList<>();
for (int i = 1; i <= count; i++) {
buffers.add(space("BUFFER-" + floor, 1, floor, i, SpaceRole.RELOCATION_BUFFER,
SpaceStatus.EMPTY, null, null, null, null));
}
return buffers;
}
private List<SpaceState> lane(String code, int capacity, int floor, Slot... slots) {
List<SpaceState> spaces = new ArrayList<>();
for (Slot slot : slots) {
spaces.add(space(code, capacity, floor, slot.depth(), SpaceRole.STORAGE, slot.status(),
slot.tray(), slot.project(), slot.product(), slot.inboundTime()));
}
return spaces;
}
private SpaceState space(String lane, int capacity, int floor, int depth, SpaceRole role,
SpaceStatus status, String tray, String project, String product, String inboundTime) {
return new SpaceState(lane + "-" + depth, "MAIN", lane, depth, capacity, floor, 6, depth,
"1", role, status, false, false, tray, project, product, "LOT",
inboundTime == null ? null : Instant.parse(inboundTime), null, false, "rv");
}
private Slot stock(int depth, String tray, String project, String product, String inboundTime) {
return new Slot(depth, SpaceStatus.OCCUPIED, tray, project, product, inboundTime);
}
private Slot empty(int depth) {
return new Slot(depth, SpaceStatus.EMPTY, null, null, null, null);
}
private record Slot(int depth, SpaceStatus status, String tray, String project, String product,
String inboundTime) {
}
}

View File

@@ -0,0 +1,32 @@
package com.ruoyi.algorithm.repository;
import org.junit.jupiter.api.Test;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class JdbcWarehouseRepositorySqlServerTest {
@Test
void incrementalRefreshCallsOnlyDirtyLaneProcedure() throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
PreparedStatement statement = mock(PreparedStatement.class);
when(dataSource.getConnection()).thenReturn(connection);
when(connection.prepareStatement(anyString())).thenReturn(statement);
when(statement.executeUpdate()).thenReturn(0);
JdbcWarehouseRepository repository = new JdbcWarehouseRepository(
dataSource, RepositoryDialect.sqlServer());
repository.refreshChangedLanes("W1");
verify(connection).prepareStatement("EXEC alg.usp_refresh_dirty_lanes @warehouse_id=?");
verify(statement).setString(1, "W1");
verify(statement).executeUpdate();
}
}

View File

@@ -0,0 +1,594 @@
package com.ruoyi.algorithm.repository;
import com.ruoyi.algorithm.inbound.InboundPolicy;
import com.ruoyi.algorithm.inbound.InboundPlanner;
import com.ruoyi.algorithm.inbound.InboundRequest;
import com.ruoyi.algorithm.inbound.BatchLaneMode;
import com.ruoyi.algorithm.outbound.OutboundPlan;
import com.ruoyi.algorithm.outbound.OutboundPlanStatus;
import com.ruoyi.algorithm.outbound.OutboundPlanner;
import com.ruoyi.algorithm.outbound.OutboundRequest;
import com.ruoyi.algorithm.outbound.OutboundWaitingCode;
import com.ruoyi.algorithm.tidy.TidyPlanner;
import com.ruoyi.algorithm.service.WarehouseApplicationService;
import org.h2.tools.RunScript;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import javax.sql.DataSource;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.Statement;
import java.sql.Types;
import java.sql.Timestamp;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class JdbcWarehouseRepositoryTest {
private DataSource dataSource;
private JdbcWarehouseRepository repository;
@BeforeEach
void setUp() throws Exception {
dataSource = new DriverManagerDataSource(
"jdbc:h2:mem:repo_" + UUID.randomUUID() + ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1", "sa", "");
try (Connection connection = dataSource.getConnection()) {
RunScript.execute(connection, new InputStreamReader(
getClass().getClassLoader().getResourceAsStream("db/h2/schema.sql"), StandardCharsets.UTF_8));
RunScript.execute(connection, new InputStreamReader(
getClass().getClassLoader().getResourceAsStream("db/h2/data.sql"), StandardCharsets.UTF_8));
}
repository = new JdbcWarehouseRepository(dataSource, RepositoryDialect.h2());
}
@Test
void pagesCandidateLanesByStableKeyWithoutDuplicatesOrSkippedCodes() throws Exception {
execute("INSERT INTO ALG_LANE_STATE VALUES ('W1','A-LANE',1,0,1,FALSE,FALSE,'',11,CURRENT_TIMESTAMP)");
execute("INSERT INTO ALG_LANE_STATE VALUES ('W1','B-LANE',1,0,1,FALSE,FALSE,'',12,CURRENT_TIMESTAMP)");
execute("INSERT INTO ALG_LANE_STATE VALUES ('W1','C-LANE',1,0,1,FALSE,FALSE,'',13,CURRENT_TIMESTAMP)");
execute("INSERT INTO ALG_LANE_STATE VALUES ('W1','SKIP-PENDING',1,0,1,TRUE,FALSE,'',14,CURRENT_TIMESTAMP)");
execute("INSERT INTO ALG_LANE_STATE VALUES ('W1','SKIP-GAP',1,0,1,FALSE,TRUE,'',15,CURRENT_TIMESTAMP)");
LanePage first = repository.pageCandidateLanes("W1", null, 2);
LanePage second = repository.pageCandidateLanes("W1", first.nextLaneCode(), 2);
assertThat(first.lanes()).extracting(LaneCandidate::laneCode).containsExactly("A-LANE", "B-LANE");
assertThat(second.lanes()).extracting(LaneCandidate::laneCode).containsExactly("C-LANE", "DENSE-1");
assertThat(first.lanes()).allMatch(lane -> lane.version().kind() == VersionToken.Kind.NUMERIC);
}
@Test
void mapsCompleteDiagnosticSnapshotsWithoutDuplicatingSpaces() throws Exception {
execute("INSERT INTO BAS_STOCK VALUES ('S-OUT-2','OUT-INNER','TRAY-OUT','P-OUT','SKU-OUT','LOT-OUT','2026-01-01 09:00:00',5,0)");
List<LaneSnapshot> lanes = repository.loadLaneSnapshots("W1",
List.of("OUT-1", "CONFLICT-1", "BUFFER-1", "GAP-1"));
assertThat(lanes).flatExtracting(LaneSnapshot::spaces).extracting(SpaceSnapshot::spaceId)
.containsExactly("BUFFER-1", "CONFLICT-1", "GAP-INNER", "GAP-OUTER", "OUT-INNER", "OUT-OUTER");
SpaceSnapshot outbound = space(lanes, "OUT-INNER");
assertThat(outbound.inventory()).hasSize(2);
assertThat(outbound.state().trayNo()).isEqualTo("TRAY-OUT");
assertThat(outbound.state().outboundTaskActive()).isTrue();
assertThat(space(lanes, "BUFFER-1").state().role()).isEqualTo(com.ruoyi.algorithm.model.SpaceRole.RELOCATION_BUFFER);
assertThat(space(lanes, "CONFLICT-1").conflicts()).contains(SpaceConflict.TRAY, SpaceConflict.PROJECT);
assertThat(space(lanes, "CONFLICT-1").state().status()).isEqualTo(com.ruoyi.algorithm.model.SpaceStatus.DISABLED);
assertThat(space(lanes, "GAP-INNER").state().status()).isEqualTo(com.ruoyi.algorithm.model.SpaceStatus.DISABLED);
assertThat(space(lanes, "BUFFER-1").state().aisleNo()).isEqualTo(1);
assertThat(space(lanes, "BUFFER-1").state().rowCode()).isEqualTo("3");
assertThat(space(lanes, "BUFFER-1").state().zoneCode()).isEqualTo("CACHE");
assertThat(space(lanes, "OUT-INNER").state().zoneCode()).isEqualTo("FLEX");
}
@Test
void statusFourPhysicalOuterPalletIsPreservedAndMakesDeepOutboundWait() throws Exception {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('ALLOC-TARGET','W1','RA','A','Target',7,21,2,'2','',"
+ "'TRAY-TARGET','ALLOC-LANE','1')");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('ALLOC-OUTER','W1','RA','A','Allocated outer',7,22,2,'4','',"
+ "'TRAY-ALLOC','ALLOC-LANE','2')");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('ALLOC-BUFFER','W1','RB','E','Floor 2 buffer',8,23,2,'1','',"
+ "NULL,'ALLOC-BUFFER','1')");
execute("INSERT INTO BAS_STOCK VALUES ('S-ALLOC-TARGET','ALLOC-TARGET','TRAY-TARGET','P-TARGET','SKU','LOT',"
+ "'2026-01-01 08:00:00',1,0)");
execute("INSERT INTO BAS_STOCK VALUES ('S-ALLOC-OUTER','ALLOC-OUTER','TRAY-ALLOC','P-BLOCK','SKU','LOT',"
+ "'2026-01-01 09:00:00',1,0)");
execute("INSERT INTO ALG_SPACE_ROLE (SPACE_ID,ROLE) VALUES ('ALLOC-BUFFER','RELOCATION_BUFFER')");
rebuildTopology("W1");
SpaceSnapshot allocated = space(repository.loadLaneSnapshots("W1", List.of("ALLOC-LANE")), "ALLOC-OUTER");
OutboundPlan result = new WarehouseApplicationService(repository, new InboundPlanner(),
new OutboundPlanner(), new TidyPlanner(), 100).planOutbound("W1",
new OutboundRequest("ALLOC-REQ", "TRAY-TARGET", null, null, "OFF", 0, 1, true));
assertThat(allocated.state().occupied()).isTrue();
assertThat(allocated.state().trayNo()).isEqualTo("TRAY-ALLOC");
assertThat(allocated.state().projectNo()).isEqualTo("P-BLOCK");
assertThat(result.status()).isEqualTo(OutboundPlanStatus.WAITING);
assertThat(result.waiting().orElseThrow().code()).isEqualTo(OutboundWaitingCode.WAITING_CHANNEL);
assertThat(result.waiting().orElseThrow().retryable()).isTrue();
assertThat(result.waiting().orElseThrow().details())
.containsEntry("blockingSpaceIds", List.of("ALLOC-OUTER"))
.containsEntry("blockingTrayNos", List.of("TRAY-ALLOC"));
}
@Test
void statusFourStockWithoutAnyTrayCodeStillBlocksDeepOutbound() throws Exception {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('NO-TRAY-TARGET','W1','RA','A','Target',7,27,2,'2','',"
+ "'TRAY-NO-TRAY-TARGET','NO-TRAY-LANE','1')");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('NO-TRAY-OUTER','W1','RA','A','Allocated outer',7,28,2,'4','',"
+ "NULL,'NO-TRAY-LANE','2')");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('NO-TRAY-BUFFER','W1','RB','E','Floor 2 buffer',8,29,2,'1','',"
+ "NULL,'NO-TRAY-BUFFER','1')");
execute("INSERT INTO BAS_STOCK VALUES ('S-NO-TRAY-TARGET','NO-TRAY-TARGET','TRAY-NO-TRAY-TARGET',"
+ "'P-TARGET','SKU','LOT','2026-01-01 08:00:00',1,0)");
execute("INSERT INTO BAS_STOCK VALUES ('S-NO-TRAY-OUTER','NO-TRAY-OUTER',NULL,"
+ "'P-BLOCK','SKU','LOT','2026-01-01 09:00:00',1,0)");
execute("INSERT INTO ALG_SPACE_ROLE (SPACE_ID,ROLE) VALUES ('NO-TRAY-BUFFER','RELOCATION_BUFFER')");
rebuildTopology("W1");
SpaceSnapshot outer = space(repository.loadLaneSnapshots("W1", List.of("NO-TRAY-LANE")),
"NO-TRAY-OUTER");
OutboundPlan result = new WarehouseApplicationService(repository, new InboundPlanner(),
new OutboundPlanner(), new TidyPlanner(), 100).planOutbound("W1",
new OutboundRequest("NO-TRAY-REQ", "TRAY-NO-TRAY-TARGET", null, null,
"OFF", 0, 1, true));
assertThat(outer.state().trayNo()).isNull();
assertThat(outer.state().occupied()).isTrue();
assertThat(result.status()).isEqualTo(OutboundPlanStatus.WAITING);
assertThat(result.waiting().orElseThrow().code()).isEqualTo(OutboundWaitingCode.WAITING_CHANNEL);
assertThat(result.waiting().orElseThrow().details())
.containsEntry("blockingSpaceIds", List.of("NO-TRAY-OUTER"));
}
@Test
void projectOnlyStatusFourStockWithoutTrayReturnsRetryableWaiting() throws Exception {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('PROJECT-NO-TRAY','W1','RA','A','Allocated source',7,30,2,'4','',"
+ "NULL,'PROJECT-NO-TRAY-LANE','1')");
execute("INSERT INTO BAS_STOCK VALUES ('S-PROJECT-NO-TRAY','PROJECT-NO-TRAY',NULL,"
+ "'P-NO-TRAY','SKU','LOT','2026-01-01 09:00:00',1,0)");
rebuildTopology("W1");
OutboundPlan result = new WarehouseApplicationService(repository, new InboundPlanner(),
new OutboundPlanner(), new TidyPlanner(), 100).planOutbound("W1",
new OutboundRequest("PROJECT-NO-TRAY-REQ", null, "P-NO-TRAY", null,
"OFF", 0, 1, true));
assertThat(result.status()).isEqualTo(OutboundPlanStatus.WAITING);
assertThat(result.waiting().orElseThrow().code()).isEqualTo(OutboundWaitingCode.WAITING_CHANNEL);
assertThat(result.waiting().orElseThrow().retryable()).isTrue();
assertThat(result.waiting().orElseThrow().details())
.containsEntry("sourceSpaceId", "PROJECT-NO-TRAY")
.containsEntry("laneCode", "PROJECT-NO-TRAY-LANE")
.doesNotContainKey("trayNo");
}
@Test
void exactCargoSpaceTrayWithoutMatchingStockIsWarehouseScopedAndWaits() throws Exception {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('CARGO-ONLY-W1','W1','RA','A','Allocated source',7,24,2,'4','',"
+ "'TRAY-CARGO-ONLY','CARGO-ONLY-LANE','1')");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('CARGO-ONLY-W2','W2','RA','A','Other warehouse',7,24,2,'2','',"
+ "'TRAY-CARGO-ONLY','CARGO-ONLY-LANE','1')");
rebuildTopology("W1");
rebuildTopology("W2");
List<OutboundStockMatch> matches = repository.searchOutboundStock("W1",
OutboundStockSearch.exactTray("TRAY-CARGO-ONLY"));
OutboundPlan result = new WarehouseApplicationService(repository, new InboundPlanner(),
new OutboundPlanner(), new TidyPlanner(), 100).planOutbound("W1",
new OutboundRequest("CARGO-ONLY-REQ", "TRAY-CARGO-ONLY", null, null,
"OFF", 0, 1, true));
assertThat(matches).extracting(OutboundStockMatch::spaceId).containsExactly("CARGO-ONLY-W1");
assertThat(result.status()).isEqualTo(OutboundPlanStatus.WAITING);
assertThat(result.waiting().orElseThrow().code()).isEqualTo(OutboundWaitingCode.WAITING_CHANNEL);
assertThat(result.waiting().orElseThrow().retryable()).isTrue();
assertThat(result.waiting().orElseThrow().details())
.containsEntry("sourceSpaceId", "CARGO-ONLY-W1")
.containsEntry("trayNo", "TRAY-CARGO-ONLY");
}
@Test
void exactTraySearchKeepsCargoAndInventoryLocationsAmbiguous() throws Exception {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('DUP-CARGO','W1','RA','A','Cargo location',7,25,2,'4','',"
+ "'TRAY-DUP','DUP-CARGO-LANE','1')");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('DUP-STOCK','W1','RA','A','Stock location',7,26,2,'2','',"
+ "NULL,'DUP-STOCK-LANE','1')");
execute("INSERT INTO BAS_STOCK VALUES ('S-DUP','DUP-STOCK','TRAY-DUP','P-DUP','SKU','LOT',"
+ "'2026-01-01 08:00:00',1,0)");
rebuildTopology("W1");
List<OutboundStockMatch> matches = repository.searchOutboundStock("W1",
OutboundStockSearch.exactTray("TRAY-DUP"));
OutboundPlan result = new WarehouseApplicationService(repository, new InboundPlanner(),
new OutboundPlanner(), new TidyPlanner(), 100).planOutbound("W1",
new OutboundRequest("DUP-REQ", "TRAY-DUP", null, null, "OFF", 0, 1, true));
assertThat(matches).extracting(OutboundStockMatch::spaceId)
.containsExactly("DUP-STOCK", "DUP-CARGO");
assertThat(result.error().orElseThrow().code()).isEqualTo(com.ruoyi.algorithm.outbound.OutboundErrorCode.AMBIGUOUS_TRAY);
}
@Test
void derivesStableJavascriptZonesFromTopologyInsteadOfAreaId() throws Exception {
for (int index = 1; index <= 134; index++) {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('Z-" + index + "','WZ','R','AREA-X','Zone',1," + index
+ ",1,'1','',NULL,'ZL-" + index + "','1')");
}
for (int depth = 1; depth <= 5; depth++) {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('MAIN-" + depth
+ "','WZ','R','AREA-Y','Main',2," + depth + ",1,'1','',NULL,'MAIN-LANE','" + depth + "')");
}
rebuildTopology("WZ");
List<LaneSnapshot> lanes = repository.loadLaneSnapshots("WZ",
List.of("ZL-134", "ZL-54", "ZL-1", "MAIN-LANE"));
assertThat(space(lanes, "Z-134").state().zoneCode()).isEqualTo("CACHE");
assertThat(space(lanes, "Z-54").state().zoneCode()).isEqualTo("RESERVE");
assertThat(space(lanes, "Z-1").state().zoneCode()).isEqualTo("FLEX");
assertThat(space(lanes, "MAIN-1").state().zoneCode()).isEqualTo("MAIN");
}
@Test
void searchesExactTrayOrProjectWithOptionalProduct() {
assertThat(repository.searchOutboundStock("W1", OutboundStockSearch.exactTray("TRAY-OUT")))
.extracting(OutboundStockMatch::spaceId).containsExactly("OUT-INNER");
assertThat(repository.searchOutboundStock("W1", OutboundStockSearch.project("P-OUT", null)))
.extracting(OutboundStockMatch::trayNo).containsExactly("TRAY-OUT");
assertThat(repository.searchOutboundStock("W1", OutboundStockSearch.project("P-OUT", "NOPE"))).isEmpty();
}
@Test
void countsOnlySelectableProjectPalletsWithInventoryOrSpaceTrayFallback() throws Exception {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('VIS-1','W1','RV','A','Visible',9,10,1,'2','','TRAY-V1','VIS-1','1')");
execute("INSERT INTO BAS_STOCK VALUES ('S-V1-A','VIS-1','TRAY-V1','P-VIS','SKU','L','2026-01-01 10:00:00',1,0)");
execute("INSERT INTO BAS_STOCK VALUES ('S-V1-B','VIS-1','TRAY-V1','P-VIS','SKU','L','2026-01-01 10:01:00',1,0)");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('VIS-2','W1','RV','A','Fallback',9,11,1,'2','','TRAY-V2','VIS-2','1')");
execute("INSERT INTO BAS_STOCK VALUES ('S-V2','VIS-2',NULL,'P-VIS','SKU','L','2026-01-01 10:02:00',1,0)");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('VIS-LOCK','W1','RV','A','Locked',9,12,1,'2','1','TRAY-L','VIS-L','1')");
execute("INSERT INTO BAS_STOCK VALUES ('S-VL','VIS-LOCK','TRAY-L','P-VIS','SKU','L','2026-01-01 10:03:00',1,0)");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('VIS-INVALID','W1','RV','A','Invalid',9,13,1,'2','','TRAY-I','VIS-I','X')");
execute("INSERT INTO BAS_STOCK VALUES ('S-VI','VIS-INVALID','TRAY-I','P-VIS','SKU','L','2026-01-01 10:04:00',1,0)");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('VIS-EMPTY','W1','RV','A','Empty mismatch',9,14,1,'1','','TRAY-E','VIS-E','1')");
execute("INSERT INTO BAS_STOCK VALUES ('S-VE','VIS-EMPTY','TRAY-E','P-VIS','SKU','L','2026-01-01 10:05:00',1,0)");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('VIS-RES','W1','RV','A','Reserved',9,15,1,'2','','TRAY-R','VIS-R','1')");
execute("INSERT INTO BAS_STOCK VALUES ('S-VR','VIS-RES','TRAY-R','P-VIS','SKU','L','2026-01-01 10:06:00',1,0)");
execute("INSERT INTO ALG_RESERVATION (RESERVATION_ID,WAREHOUSE_ID,LANE_CODE,SPACE_ID,PROJECT_NO,STATUS) VALUES ('"
+ UUID.randomUUID() + "','W1','VIS-R','VIS-RES','P-VIS','ACTIVE')");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('VIS-PEND','W1','RV','A','Pending',9,16,1,'2','','TRAY-P','VIS-P','1')");
execute("INSERT INTO BAS_STOCK VALUES ('S-VP','VIS-PEND','TRAY-P','P-VIS','SKU','L','2026-01-01 10:07:00',1,0)");
execute("INSERT INTO MYDO_JOB VALUES ('JOB-VP','TASK-VP','TRAY-P','VIS-PEND',NULL,'W1','3',21,'2026-01-01 11:00:00','')");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('VIS-GAP-1','W1','RV','A','Gap inner',9,17,1,'1','',NULL,'VIS-G','1')");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('VIS-GAP-2','W1','RV','A','Gap outer',9,18,1,'2','','TRAY-G','VIS-G','2')");
execute("INSERT INTO BAS_STOCK VALUES ('S-VG','VIS-GAP-2','TRAY-G','P-VIS','SKU','L','2026-01-01 10:08:00',1,0)");
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('VIS-CONFLICT','W1','RV','A','Conflict',9,19,1,'2','','TRAY-C','VIS-C','1')");
execute("INSERT INTO BAS_STOCK VALUES ('S-VC1','VIS-CONFLICT','TRAY-C1','P-VIS','SKU','L','2026-01-01 10:09:00',1,0)");
execute("INSERT INTO BAS_STOCK VALUES ('S-VC2','VIS-CONFLICT','TRAY-C2','P-VIS','SKU','L','2026-01-01 10:10:00',1,0)");
rebuildTopology("W1");
assertThat(repository.countProjectPallets("W1", "P-VIS")).isEqualTo(2);
}
@Test
void treatsMultipleProductAndLotDetailsAsOneVisibleProjectPallet() throws Exception {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('VIS-LOT','W1','RV','A','Detailed pallet',9,20,1,'2','','TRAY-LOT','VIS-LOT','1')");
execute("INSERT INTO BAS_STOCK VALUES ('S-VLOT-1','VIS-LOT','TRAY-LOT','P-LOT','SKU-1','LOT-1','2026-01-01 10:00:00',1,0)");
execute("INSERT INTO BAS_STOCK VALUES ('S-VLOT-2','VIS-LOT','TRAY-LOT','P-LOT','SKU-2','LOT-2','2026-01-01 10:01:00',1,0)");
rebuildTopology("W1");
SpaceSnapshot snapshot = space(repository.loadLaneSnapshots("W1", List.of("VIS-LOT")), "VIS-LOT");
assertThat(snapshot.conflicts()).isEmpty();
assertThat(snapshot.state().trayNo()).isEqualTo("TRAY-LOT");
assertThat(snapshot.state().projectNo()).isEqualTo("P-LOT");
assertThat(snapshot.state().productId()).isNull();
assertThat(snapshot.state().lotNo()).isNull();
assertThat(repository.countProjectPallets("W1", "P-LOT")).isOne();
assertThat(repository.searchOutboundStock("W1", OutboundStockSearch.project("P-LOT", "SKU-2")))
.extracting(OutboundStockMatch::spaceId).containsExactly("VIS-LOT");
assertThat(repository.searchOutboundStock("W1", OutboundStockSearch.project("P-LOT", "SKU-X"))).isEmpty();
}
@Test
void insertsAndReadsCompactDecisionLog() {
String decisionId = UUID.randomUUID().toString();
DecisionLogEntry inserted = repository.insertDecision(new DecisionLogRecord(
decisionId, "W1", "INBOUND", VersionToken.numeric(7), "READY", "selected AVAILABLE-1"));
assertThat(repository.findDecision(inserted.decisionId())).contains(inserted);
assertThat(inserted.reason()).doesNotContain("password");
}
@Test
void bindsDecisionVersionWithTheDialectColumnType() {
assertThat(JdbcWarehouseRepository.decisionVersionSqlType(RepositoryDialect.sqlServer()))
.isEqualTo(Types.BINARY);
assertThat(JdbcWarehouseRepository.decisionVersionSqlType(RepositoryDialect.h2()))
.isEqualTo(Types.BIGINT);
}
@Test
void loadsSummaryPoliciesPendingJobsAndSameFloorBuffersWithoutDetailSnapshots() {
WarehouseSummary summary = repository.loadSummary("W1");
assertThat(summary.spaceCount()).isEqualTo(12);
assertThat(summary.conflictSpaceCount()).isEqualTo(1);
assertThat(repository.loadPolicy("W1")).isEqualTo(RepositoryPolicy.defaults());
assertThat(repository.loadPendingOutboundJobs("W1")).extracting(PendingOutboundJob::jobId)
.containsExactly("JOB-OUT-1");
assertThat(repository.loadRelocationBuffers("W1", java.util.Set.of(1)))
.extracting(SpaceSnapshot::spaceId).containsExactly("BUFFER-1");
}
@Test
void projectPolicyOnlyDeniesMixingWhenExplicitlyConfiguredFalse() throws Exception {
execute("INSERT INTO ALG_PROJECT_POLICY (PROJECT_NO,IS_ISOLATED,IS_COMMON,ALLOW_MIXED) "
+ "VALUES ('P-DENIED',FALSE,FALSE,FALSE)");
execute("INSERT INTO ALG_PROJECT_POLICY (PROJECT_NO,IS_ISOLATED,IS_COMMON,ALLOW_MIXED) "
+ "VALUES ('P-ALLOWED',FALSE,FALSE,TRUE)");
InboundPolicy policy = repository.loadPolicy("W1").inboundPolicy();
assertThat(policy.allowsMixed("P-DENIED")).isFalse();
assertThat(policy.allowsMixed("P-ALLOWED")).isTrue();
assertThat(policy.allowsMixed("P-UNCONFIGURED")).isTrue();
}
@Test
void reservesWithOpaqueVersionAndReturnsStructuredConflictsAndRelease() {
VersionToken version = repository.pageCandidateLanes("W1", "C-LANE", 10).lanes().stream()
.filter(lane -> lane.laneCode().equals("DENSE-1")).findFirst().orElseThrow().version();
String reservationId = UUID.randomUUID().toString();
ReservationResult reserved = repository.tryReserveInbound(new InboundReservation(
reservationId, "W1", "DENSE-1", "DENSE-1", "P-NEW", version));
ReservationResult stale = repository.tryReserveInbound(new InboundReservation(
UUID.randomUUID().toString(), "W1", "DENSE-1", "DENSE-2", "P-NEW", version));
ReservationResult nullVersion = repository.tryReserveInbound(new InboundReservation(
UUID.randomUUID().toString(), "W1", "DENSE-1", "DENSE-2", "P-NEW", null));
VersionToken current = repository.loadLaneSnapshots("W1", List.of("DENSE-1")).get(0).version();
ReservationResult duplicate = repository.tryReserveInbound(new InboundReservation(
UUID.randomUUID().toString(), "W1", "DENSE-1", "DENSE-1", "P-NEW", current));
assertThat(reserved.status()).isEqualTo(ReservationStatus.RESERVED);
assertThat(stale.reasonCode()).isEqualTo("LANE_VERSION_MISMATCH");
assertThat(nullVersion.reasonCode()).isEqualTo("LANE_VERSION_MISMATCH");
assertThat(duplicate.reasonCode()).isEqualTo("ACTIVE_RESERVATION_EXISTS");
assertThat(repository.releaseReservation("W1", reservationId).status()).isEqualTo(ReservationStatus.RELEASED);
assertThat(repository.releaseReservation("W1", reservationId).status()).isEqualTo(ReservationStatus.NOT_FOUND);
}
@Test
void synchronizationInvalidatesSameCountInventoryAndPolicyChangesAndLiveGuardRejectsStaleToken() throws Exception {
repository.synchronizeReadModel("W1");
VersionToken inventoryBefore = repository.loadLaneSnapshots("W1", List.of("OUT-1")).get(0).version();
execute("UPDATE BAS_STOCK SET SPARE4='P-REPLACED' WHERE STOCK_ID='S-OUT-1'");
repository.synchronizeReadModel("W1");
VersionToken inventoryAfter = repository.loadLaneSnapshots("W1", List.of("OUT-1")).get(0).version();
execute("INSERT INTO ALG_PROJECT_POLICY (PROJECT_NO,IS_ISOLATED,IS_COMMON,ALLOW_MIXED) "
+ "VALUES ('P-SEMANTIC',TRUE,FALSE,FALSE)");
repository.synchronizeReadModel("W1");
VersionToken policyAfter = repository.loadLaneSnapshots("W1", List.of("OUT-1")).get(0).version();
VersionToken stale = repository.loadLaneSnapshots("W1", List.of("DENSE-1")).get(0).version();
execute("UPDATE ALG_PROJECT_POLICY SET IS_ISOLATED=FALSE,ALLOW_MIXED=TRUE WHERE PROJECT_NO='P-SEMANTIC'");
ReservationResult conflict = repository.tryReserveInbound(new InboundReservation(
UUID.randomUUID().toString(), "W1", "DENSE-1", "DENSE-1", "P-NEW", stale));
assertThat(inventoryAfter).isNotEqualTo(inventoryBefore);
assertThat(policyAfter).isNotEqualTo(inventoryAfter);
assertThat(conflict.reasonCode()).isEqualTo("LANE_VERSION_MISMATCH");
}
@Test
void changedJobRefreshAdvancesOnlyItsResolvedSourceLane() throws Exception {
repository.synchronizeReadModel("W1");
VersionToken denseBefore = repository.loadLaneSnapshots("W1", List.of("DENSE-1")).get(0).version();
VersionToken outboundBefore = repository.loadLaneSnapshots("W1", List.of("OUT-1")).get(0).version();
execute("INSERT INTO MYDO_JOB (JOB_ID,TASK_NO,TRAY_CODE,CARGO_SPACE_ID,TARGET_CARGO_SPACE_ID,"
+ "WAREHOUSE_ID,STATUS,IN_OR_OUT,CREATE_TIME,FINISH_TIME) VALUES "
+ "('JOB-DENSE-OUT','TASK-DENSE-OUT',NULL,'DENSE-1',NULL,'W1','1',21,"
+ "'2026-07-21 08:00:00','')");
repository.refreshChangedLanes("W1");
VersionToken denseAfter = repository.loadLaneSnapshots("W1", List.of("DENSE-1")).get(0).version();
VersionToken outboundAfter = repository.loadLaneSnapshots("W1", List.of("OUT-1")).get(0).version();
assertThat(denseAfter).isNotEqualTo(denseBefore);
assertThat(outboundAfter).isEqualTo(outboundBefore);
}
@Test
void incrementalRefreshDoesNotRewriteUnchangedLaneSnapshots() throws Exception {
repository.synchronizeReadModel("W1");
execute("UPDATE ALG_INVENTORY_ITEM_SNAPSHOT SET SYNCHRONIZED_AT='2000-01-01 00:00:00' "
+ "WHERE STOCK_ID='S-CONFLICT-1'");
executeLiveOnly("UPDATE BAS_STOCK SET SPARE4='P-REFRESHED' WHERE STOCK_ID='S-OUT-1'");
repository.refreshChangedLanes("W1");
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
var result = statement.executeQuery("SELECT SYNCHRONIZED_AT FROM ALG_INVENTORY_ITEM_SNAPSHOT "
+ "WHERE STOCK_ID='S-CONFLICT-1'");
result.next();
assertThat(result.getTimestamp(1)).isEqualTo(Timestamp.valueOf("2000-01-01 00:00:00"));
}
assertThat(repository.searchOutboundStock("W1", OutboundStockSearch.project("P-REFRESHED", null)))
.extracting(OutboundStockMatch::stockId).containsExactly("S-OUT-1");
}
@Test
void incrementalRefreshRebuildsTopologyOnlyForStructuralCargoChanges() throws Exception {
repository.synchronizeReadModel("W1");
executeLiveOnly("UPDATE BAS_CARGO_SPACE SET LOCKED='1' WHERE CARGO_SPACE_ID='AVAILABLE-1'");
repository.refreshChangedLanes("W1");
assertThat(space(repository.loadLaneSnapshots("W1", List.of("AVAILABLE-1")), "AVAILABLE-1")
.state().status()).isEqualTo(com.ruoyi.algorithm.model.SpaceStatus.DISABLED);
executeLiveOnly("INSERT INTO BAS_CARGO_SPACE (CARGO_SPACE_ID,WAREHOUSE_ID,ROADWAY_ID,CARGO_AREA_ID,"
+ "CARGO_SPACE_NAME,THE_PLATOON,THE_COLUMN,THE_FLOOR,STATUS,LOCKED,TRAY_CODE,"
+ "GROUP_CODING_NO,GROUP_CODING) VALUES "
+ "('NEW-STRUCTURE-1','W1','R9','A','New structure',9,1,1,'1','',NULL,"
+ "'NEW-STRUCTURE','1')");
repository.refreshChangedLanes("W1");
assertThat(repository.loadLaneSnapshots("W1", List.of("NEW-STRUCTURE")))
.singleElement().satisfies(lane -> assertThat(lane.spaces())
.extracting(SpaceSnapshot::spaceId).containsExactly("NEW-STRUCTURE-1"));
}
@Test
void planningRefreshesChangedLiveProjectBeforeSelectingAndReserving() throws Exception {
execute("UPDATE BAS_CARGO_SPACE SET STATUS='2',TRAY_CODE='TRAY-P-SNAPSHOT' "
+ "WHERE CARGO_SPACE_ID='DENSE-1'");
execute("INSERT INTO BAS_STOCK VALUES ('S-P-SNAPSHOT','DENSE-1','TRAY-P-SNAPSHOT',"
+ "'P-SNAPSHOT','SKU-P','LOT-P','2026-01-01 08:00:00',1,0)");
repository.synchronizeReadModel("W1");
VersionToken synchronizedVersion = repository.loadLaneSnapshots("W1", List.of("DENSE-1"))
.get(0).version();
String synchronizedReservation = UUID.randomUUID().toString();
assertThat(repository.tryReserveInbound(new InboundReservation(synchronizedReservation, "W1",
"DENSE-1", "DENSE-2", "P-SNAPSHOT", synchronizedVersion)).status())
.isEqualTo(ReservationStatus.RESERVED);
assertThat(repository.releaseReservation("W1", synchronizedReservation).status())
.isEqualTo(ReservationStatus.RELEASED);
executeLiveOnly("UPDATE BAS_STOCK SET SPARE4='Q-LIVE' WHERE STOCK_ID='S-P-SNAPSHOT'");
String rebuildTrigger = UUID.randomUUID().toString();
execute("INSERT INTO ALG_RESERVATION (RESERVATION_ID,WAREHOUSE_ID,LANE_CODE,SPACE_ID,PROJECT_NO,STATUS) "
+ "VALUES ('" + rebuildTrigger + "','W1','DENSE-1','DENSE-2','P-SNAPSHOT','ACTIVE')");
assertThat(repository.releaseReservation("W1", rebuildTrigger).status())
.isEqualTo(ReservationStatus.RELEASED);
WarehouseApplicationService service = new WarehouseApplicationService(repository, new InboundPlanner(),
new OutboundPlanner(), new TidyPlanner(), 100);
var planned = service.planInbound("W1", new InboundRequest("STALE-SNAPSHOT-PLAN", "TRAY-IN", "P-SNAPSHOT",
"SKU-P", "LOT-P", 2, 1, null, Set.of(), BatchLaneMode.OPEN));
var assignment = planned.plan().assignment().orElseThrow();
assertThat(assignment.laneCode()).isEqualTo("AVAILABLE-1");
assertThat(planned.laneVersion()).isNotNull();
ReservationResult reserved = repository.tryReserveInbound(new InboundReservation(
UUID.randomUUID().toString(), "W1", assignment.laneCode(), assignment.spaceId(),
"P-SNAPSHOT", planned.laneVersion()));
assertThat(reserved.status()).isEqualTo(ReservationStatus.RESERVED);
}
@Test
void pendingOutboundConflictPrecedesGenericEligibilityAndIdsAreUuidForBothDialects() throws Exception {
execute("INSERT INTO ALG_LANE_STATE VALUES ('W1','OUT-1',2,1,0,TRUE,FALSE,'',21,CURRENT_TIMESTAMP)");
String reservationId = UUID.randomUUID().toString();
ReservationResult result = repository.tryReserveInbound(new InboundReservation(
reservationId, "W1", "OUT-1", "OUT-OUTER", "P", VersionToken.numeric(21)));
assertThat(result.reasonCode()).isEqualTo("PENDING_OUTBOUND");
assertThatThrownBy(() -> repository.tryReserveInbound(new InboundReservation(
"not-a-uuid", "W1", "DENSE-1", "DENSE-1", "P", VersionToken.numeric(7))))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void livePendingJobBlocksReservationWithoutLaneStateRebuild() throws Exception {
VersionToken version = repository.pageCandidateLanes("W1", null, 100).lanes().stream()
.filter(lane -> lane.laneCode().equals("DENSE-1")).findFirst().orElseThrow().version();
execute("INSERT INTO MYDO_JOB VALUES ('JOB-LIVE','TASK-LIVE',NULL,'DENSE-1',NULL,'W1','3',21,"
+ "'2026-07-20 10:00:00','')");
ReservationResult result = repository.tryReserveInbound(new InboundReservation(
UUID.randomUUID().toString(), "W1", "DENSE-1", "DENSE-1", "P", version));
assertThat(result.reasonCode()).isEqualTo("PENDING_OUTBOUND");
}
@Test
void deepWorkflowLocksAreOwnedExclusiveAndExcludeTheCurrentRequest() {
assertThat(repository.acquireDeepWorkflowLock("REQ-1", "W1", 1)).isTrue();
assertThat(repository.acquireDeepWorkflowLock("REQ-1", "W1", 1)).isTrue();
assertThat(repository.acquireDeepWorkflowLock("REQ-2", "W1", 1)).isFalse();
assertThat(repository.loadActiveDeepWorkflowFloors("W1", "REQ-1")).isEmpty();
assertThat(repository.loadActiveDeepWorkflowFloors("W1", "REQ-2")).containsExactly(1);
assertThat(repository.releaseDeepWorkflowLock("REQ-2", "W1", 1)).isFalse();
assertThat(repository.releaseDeepWorkflowLock("REQ-1", "W1", 1)).isTrue();
assertThat(repository.loadActiveDeepWorkflowFloors("W1", null)).isEmpty();
}
@Test
void ordinaryOutboundJobsDoNotCreateDeepWorkflowLocks() throws Exception {
execute("DELETE FROM MYDO_JOB");
execute("INSERT INTO MYDO_JOB VALUES ('JOB-DEEP','TASK-DEEP',NULL,'DENSE-1',NULL,'W1','3',21,"
+ "'2026-07-20 10:00:00','')");
assertThat(repository.loadActiveDeepWorkflowFloors("W1", null)).isEmpty();
}
@Test
void dialectConstructionIsFixedAndRepositorySqlHasNoPlannerExpressions() throws Exception {
assertThat(RepositoryDialect.class.getDeclaredConstructors()).allMatch(constructor ->
java.lang.reflect.Modifier.isPrivate(constructor.getModifiers()));
String source = java.nio.file.Files.readString(java.nio.file.Path.of(
"src/main/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepository.java"));
assertThat(source).doesNotContain("capacityScore", "efficiencyScore", "ladderRung", "targetDepth",
"expectedProjectPallets", "ROW_NUMBER", "LANE_TOPOLOGY");
}
private SpaceSnapshot space(List<LaneSnapshot> lanes, String id) {
return lanes.stream().flatMap(lane -> lane.spaces().stream())
.filter(space -> space.spaceId().equals(id)).findFirst().orElseThrow();
}
private void execute(String sql) throws Exception {
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
statement.executeUpdate(sql);
if (java.util.regex.Pattern.compile("(?i)\\bBAS_STOCK\\b").matcher(sql).find()) {
statement.executeUpdate("DELETE FROM ALG_SPACE_INVENTORY_SUMMARY");
statement.executeUpdate("DELETE FROM ALG_INVENTORY_ITEM_SNAPSHOT");
statement.executeUpdate("INSERT INTO ALG_INVENTORY_ITEM_SNAPSHOT "
+ "(WAREHOUSE_ID,STOCK_ID,SPACE_ID,TRAY_CODE,PROJECT_NO,PRODUCT_NO,LOT_NO,"
+ "INBOUND_TIME_RAW,INBOUND_AT,QUANTITY,OUT_QUANTITY) "
+ "SELECT C.WAREHOUSE_ID,S.STOCK_ID,S.CARGO_SPACE_ID,S.TRAY_CODE,S.SPARE4,S.PRODUCT_ID,"
+ "S.LOT_NO,S.IN_DATE,ALG_TRY_TIMESTAMP(S.IN_DATE),S.QUANTITY,S.OUT_QUANTITY "
+ "FROM BAS_STOCK S JOIN BAS_CARGO_SPACE C ON C.CARGO_SPACE_ID=S.CARGO_SPACE_ID");
statement.executeUpdate("INSERT INTO ALG_SPACE_INVENTORY_SUMMARY "
+ "(WAREHOUSE_ID,SPACE_ID,STOCK_DETAIL_COUNT,STOCK_TRAY_COUNT,STOCK_PROJECT_COUNT,STOCK_PRODUCT_COUNT) "
+ "SELECT WAREHOUSE_ID,SPACE_ID,COUNT(*),COUNT(DISTINCT NULLIF(TRIM(TRAY_CODE),'')),"
+ "COUNT(DISTINCT NULLIF(TRIM(PROJECT_NO),'')),COUNT(DISTINCT NULLIF(TRIM(PRODUCT_NO),'')) "
+ "FROM ALG_INVENTORY_ITEM_SNAPSHOT GROUP BY WAREHOUSE_ID,SPACE_ID");
}
}
}
private void executeLiveOnly(String sql) throws Exception {
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
statement.executeUpdate(sql);
}
}
private void rebuildTopology(String warehouseId) throws Exception {
execute("DELETE FROM ALG_SPACE_TOPOLOGY WHERE WAREHOUSE_ID = '" + warehouseId + "'");
execute("INSERT INTO ALG_SPACE_TOPOLOGY (WAREHOUSE_ID,SPACE_ID,LANE_CODE,LANE_DEPTH,BASE_ZONE) "
+ "WITH LANE_TOPOLOGY AS (SELECT WAREHOUSE_ID,GROUP_CODING_NO AS LANE_CODE,COUNT(*) AS LANE_DEPTH,"
+ "MAX(THE_COLUMN) AS COLUMN_NO,MIN(THE_PLATOON) AS ROW_NO,MIN(THE_FLOOR) AS FLOOR_NO,"
+ "MIN(CARGO_SPACE_ID) AS STABLE_ID FROM BAS_CARGO_SPACE WHERE WAREHOUSE_ID = '" + warehouseId
+ "' GROUP BY WAREHOUSE_ID,GROUP_CODING_NO), RANKED_LANES AS (SELECT LANE_TOPOLOGY.*,"
+ "CASE WHEN LANE_DEPTH=1 THEN ROW_NUMBER() OVER (PARTITION BY WAREHOUSE_ID ORDER BY "
+ "CASE WHEN LANE_DEPTH=1 THEN 0 ELSE 1 END,COLUMN_NO DESC,ROW_NO ASC,FLOOR_NO ASC,STABLE_ID ASC) END "
+ "AS SINGLE_DEPTH_RANK FROM LANE_TOPOLOGY) SELECT C.WAREHOUSE_ID,C.CARGO_SPACE_ID,C.GROUP_CODING_NO,"
+ "R.LANE_DEPTH,CASE WHEN R.LANE_DEPTH=1 AND R.SINGLE_DEPTH_RANK<=80 THEN 'CACHE' "
+ "WHEN R.LANE_DEPTH=1 AND R.SINGLE_DEPTH_RANK<=133 THEN 'RESERVE' WHEN R.LANE_DEPTH=5 THEN 'MAIN' "
+ "ELSE 'FLEX' END FROM BAS_CARGO_SPACE C JOIN RANKED_LANES R ON R.WAREHOUSE_ID=C.WAREHOUSE_ID "
+ "AND R.LANE_CODE=C.GROUP_CODING_NO WHERE C.WAREHOUSE_ID='" + warehouseId + "'");
}
}

View File

@@ -0,0 +1,41 @@
package com.ruoyi.algorithm.repository;
import org.h2.tools.RunScript;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class JdbcWarehouseRepositoryWriteGuardTest {
@Test
void disabledWritesKeepPlanningAuditInMemoryAndRejectAllShadowMutations() throws Exception {
var dataSource = new DriverManagerDataSource(
"jdbc:h2:mem:guard_" + UUID.randomUUID() + ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1", "sa", "");
try (var connection = dataSource.getConnection()) {
RunScript.execute(connection, new InputStreamReader(
getClass().getClassLoader().getResourceAsStream("db/h2/schema.sql"), StandardCharsets.UTF_8));
RunScript.execute(connection, new InputStreamReader(
getClass().getClassLoader().getResourceAsStream("db/h2/data.sql"), StandardCharsets.UTF_8));
}
JdbcWarehouseRepository repository = new JdbcWarehouseRepository(dataSource, RepositoryDialect.h2(), false);
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
int before = jdbc.queryForObject("SELECT COUNT(*) FROM ALG_DECISION_LOG", Integer.class);
DecisionLogEntry entry = repository.insertDecision(new DecisionLogRecord(UUID.randomUUID().toString(),
"W1", "INBOUND", VersionToken.numeric(7), "READY", "read-only planning"));
assertThat(entry.createdAt()).isBeforeOrEqualTo(Instant.now());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ALG_DECISION_LOG", Integer.class)).isEqualTo(before);
assertThat(repository.tryReserveInbound(new InboundReservation(UUID.randomUUID().toString(), "W1",
"DENSE-1", "DENSE-1", "P", VersionToken.numeric(7))).reasonCode()).isEqualTo("WRITES_DISABLED");
assertThat(repository.releaseReservation("W1", UUID.randomUUID().toString()).reasonCode()).isEqualTo("WRITES_DISABLED");
assertThat(repository.acquireDeepWorkflowLock("O-1", "W1", 1)).isFalse();
assertThat(repository.releaseDeepWorkflowLock("O-1", "W1", 1)).isFalse();
}
}

View File

@@ -0,0 +1,200 @@
package com.ruoyi.algorithm.repository;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class SqlServerMigrationScriptTest {
@Test
void installDefinesPersistentOutboundWorkflowSchema() throws IOException {
String install = resource("db/sqlserver/install.sql");
assertThat(install).contains(
"CREATE TABLE alg.outbound_workflow (",
"CREATE TABLE alg.outbound_workflow_step (",
"CREATE TABLE alg.outbound_workflow_space (",
"CK_alg_outbound_workflow_status",
"CK_alg_outbound_workflow_step_phase",
"CK_alg_outbound_workflow_step_status",
"UX_alg_outbound_workflow_active_request",
"UX_alg_outbound_workflow_step_wms_job",
"UX_alg_outbound_workflow_space_active");
}
@Test
void installDefinesDirtyLaneQueueRefreshAndAllMutationSources() throws IOException {
String install = resource("db/sqlserver/install.sql");
assertThat(install).contains(
"CREATE TABLE alg.dirty_lane_queue (",
"CREATE PROCEDURE alg.usp_mark_dirty_lane",
"CREATE PROCEDURE alg.usp_refresh_dirty_lanes",
"CREATE TRIGGER dbo.trg_alg_dirty_bas_stock",
"CREATE TRIGGER dbo.trg_alg_dirty_bas_cargo_space",
"CREATE TRIGGER dbo.trg_alg_dirty_mydo_job",
"CREATE TRIGGER alg.trg_alg_dirty_space_role",
"CREATE TRIGGER alg.trg_alg_dirty_project_policy",
"CREATE TRIGGER alg.trg_alg_dirty_warehouse_policy");
}
@Test
void installDefinesPersistentTidyRuntimeSchemaAndConstraints() throws IOException {
String install = resource("db/sqlserver/install.sql");
assertThat(install).contains(
"CREATE TABLE alg.tidy_mode (",
"CREATE TABLE alg.tidy_lane_queue (",
"CREATE TABLE alg.tidy_workflow (",
"CREATE TABLE alg.tidy_step (",
"CREATE TABLE alg.tidy_space_lock (",
"CREATE TABLE alg.tidy_callback_event (",
"CK_alg_tidy_mode_effective_state",
"CK_alg_tidy_workflow_origin",
"CK_alg_tidy_workflow_status",
"CK_alg_tidy_space_lock_resource_type",
"UX_alg_tidy_space_lock_active",
"UX_alg_tidy_callback_event_key",
"UX_alg_tidy_workflow_active_auto",
"FK_alg_tidy_callback_event_step_workflow");
}
@Test
void installDefinesVersionedTidyLaneClaimAndSequenceSynchronization() throws IOException {
String install = resource("db/sqlserver/install.sql");
assertThat(install).contains(
"CREATE PROCEDURE alg.usp_claim_tidy_lane",
"@eligible_lane_codes varchar(max) = NULL",
"STRING_SPLIT(@eligible_lane_codes, '','')",
"CREATE PROCEDURE alg.usp_release_tidy_lane",
"CREATE PROCEDURE alg.usp_sync_replay_sequences",
"sp_getapplock",
"CK202607210002");
}
@Test
void installCompletesAlgorithmOwnedMoveJobsWithoutChangingTheLegacyProcedure() throws IOException {
String install = resource("db/sqlserver/install.sql");
assertThat(install).contains(
"CREATE PROCEDURE alg.usp_complete_move_job",
"in_or_out = ''28''",
"UPDATE dbo.bas_stock",
"INSERT INTO dbo.mydo_job_finish",
"DELETE FROM dbo.mydo_job");
assertThat(install).doesNotContain("ALTER PROCEDURE dbo.proc_yk_complete");
}
@Test
void dirtyLaneRefreshPublishesRebuiltLanesToPersistentTidyQueue() throws IOException {
String install = resource("db/sqlserver/install.sql");
String procedure = install.substring(
install.indexOf("CREATE PROCEDURE alg.usp_refresh_dirty_lanes"),
install.indexOf("IF OBJECT_ID(N'alg.usp_try_reserve_inbound'"));
int rebuildLaneState = procedure.lastIndexOf("EXEC alg.usp_rebuild_lane_state");
int publishTidyLane = procedure.indexOf("MERGE alg.tidy_lane_queue WITH (HOLDLOCK)");
assertThat(rebuildLaneState).isGreaterThanOrEqualTo(0);
assertThat(publishTidyLane).isGreaterThan(rebuildLaneState);
assertThat(procedure).contains(
"dirty_version = target.dirty_version + 1",
"queue_status = ''PENDING''");
}
@Test
void dirtyLaneRefreshReleasesQueueLocksBeforeReadingWarehouseTablesAndRequeuesFailures()
throws IOException {
String install = resource("db/sqlserver/install.sql");
String procedure = install.substring(
install.indexOf("CREATE PROCEDURE alg.usp_refresh_dirty_lanes"),
install.indexOf("IF OBJECT_ID(N'alg.usp_try_reserve_inbound'"));
int claimDelete = procedure.indexOf("DELETE queue");
int claimCommit = procedure.indexOf("COMMIT TRANSACTION;", claimDelete);
int warehouseRead = procedure.indexOf("FROM dbo.bas_cargo_space", claimDelete);
assertThat(claimDelete).isGreaterThanOrEqualTo(0);
assertThat(claimCommit).isBetween(claimDelete + 1, warehouseRead - 1);
assertThat(procedure).contains(
"BEGIN TRY",
"BEGIN CATCH",
"MERGE alg.dirty_lane_queue WITH (HOLDLOCK) AS target",
"THROW;");
}
@Test
void topologyRebuildExcludesUnconfiguredCargoSpacesAndDiagnosticsReportsThem()
throws IOException {
String install = resource("db/sqlserver/install.sql");
String diagnostics = resource("db/sqlserver/diagnostics.sql");
String procedure = install.substring(
install.indexOf("CREATE PROCEDURE alg.usp_rebuild_space_topology"),
install.indexOf("IF OBJECT_ID(N'alg.usp_sync_inventory_snapshot'"));
assertThat(procedure).contains(
"NULLIF(LTRIM(RTRIM(group_coding_no)), '''') IS NOT NULL",
"TRY_CONVERT(int, group_coding) IS NOT NULL");
assertThat(diagnostics).contains(
"excluded_from_algorithm_topology_count",
"group_coding_no IS NULL OR LTRIM(RTRIM(group_coding_no)) = ''",
"TRY_CONVERT(int, group_coding) IS NULL");
}
@Test
void diagnosticsAndUninstallCoverNewRuntimeObjects() throws IOException {
String diagnostics = resource("db/sqlserver/diagnostics.sql");
String uninstall = resource("db/sqlserver/uninstall.sql");
assertThat(diagnostics).contains(
"FROM alg.dirty_lane_queue",
"FROM alg.outbound_workflow",
"FROM alg.tidy_mode",
"FROM alg.tidy_lane_queue",
"FROM alg.tidy_workflow",
"FROM alg.tidy_space_lock",
"PAUSED_RECOVERY");
assertThat(uninstall).contains(
"DROP TRIGGER dbo.trg_alg_dirty_bas_stock",
"DROP PROCEDURE alg.usp_refresh_dirty_lanes",
"DROP PROCEDURE alg.usp_claim_tidy_lane",
"DROP PROCEDURE alg.usp_release_tidy_lane",
"DROP PROCEDURE alg.usp_sync_replay_sequences",
"DROP TABLE alg.tidy_callback_event",
"DROP TABLE alg.tidy_space_lock",
"DROP TABLE alg.tidy_step",
"DROP TABLE alg.tidy_workflow",
"DROP TABLE alg.tidy_lane_queue",
"DROP TABLE alg.tidy_mode",
"DROP TABLE alg.outbound_workflow_space",
"DROP TABLE alg.outbound_workflow_step",
"DROP TABLE alg.outbound_workflow",
"DROP TABLE alg.dirty_lane_queue");
}
@Test
void repeatedInstallDoesNotDiscardPendingDirtyLanes() throws IOException {
String install = resource("db/sqlserver/install.sql");
String initialization = install.substring(install.lastIndexOf("IF NOT EXISTS (SELECT 1 FROM alg.space_topology)"));
assertThat(initialization).contains(
"AND NOT EXISTS (SELECT 1 FROM alg.inventory_item_snapshot)",
"AND NOT EXISTS (SELECT 1 FROM alg.lane_state)");
assertThat(initialization.indexOf("DELETE FROM alg.dirty_lane_queue"))
.isLessThan(initialization.indexOf("END;"));
assertThat(initialization.substring(initialization.indexOf("END;") + 4))
.doesNotContain("DELETE FROM alg.dirty_lane_queue");
}
private String resource(String name) throws IOException {
try (var stream = getClass().getClassLoader().getResourceAsStream(name)) {
if (stream == null) {
throw new IOException("Missing test resource " + name);
}
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
}
}

View File

@@ -0,0 +1,568 @@
package com.ruoyi.algorithm.service;
import com.ruoyi.algorithm.inbound.BatchLaneMode;
import com.ruoyi.algorithm.inbound.InboundPlanner;
import com.ruoyi.algorithm.inbound.InboundRequest;
import com.ruoyi.algorithm.model.SpaceRole;
import com.ruoyi.algorithm.model.SpaceState;
import com.ruoyi.algorithm.model.SpaceStatus;
import com.ruoyi.algorithm.outbound.OutboundPlanStatus;
import com.ruoyi.algorithm.outbound.OutboundErrorCode;
import com.ruoyi.algorithm.outbound.OutboundPlanner;
import com.ruoyi.algorithm.outbound.OutboundRequest;
import com.ruoyi.algorithm.tidy.TidyPlanner;
import com.ruoyi.algorithm.repository.DecisionLogEntry;
import com.ruoyi.algorithm.repository.InboundReservation;
import com.ruoyi.algorithm.repository.LaneCandidate;
import com.ruoyi.algorithm.repository.LanePage;
import com.ruoyi.algorithm.repository.LaneSnapshot;
import com.ruoyi.algorithm.repository.OutboundStockMatch;
import com.ruoyi.algorithm.repository.RepositoryPolicy;
import com.ruoyi.algorithm.repository.ReservationResult;
import com.ruoyi.algorithm.repository.SpaceSnapshot;
import com.ruoyi.algorithm.repository.VersionToken;
import com.ruoyi.algorithm.repository.WarehouseRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class WarehouseApplicationServiceTest {
private WarehouseRepository repository;
private WarehouseApplicationService service;
@BeforeEach
void setUp() {
repository = mock(WarehouseRepository.class);
service = new WarehouseApplicationService(repository, new InboundPlanner(), new OutboundPlanner(),
new TidyPlanner(), 100);
when(repository.loadPolicy("W1")).thenReturn(RepositoryPolicy.defaults());
when(repository.loadSummary("W1")).thenReturn(new com.ruoyi.algorithm.repository.WarehouseSummary(
"W1", 0, 0, 0, 0, 0, 0, 0));
when(repository.loadPendingOutboundJobs("W1")).thenReturn(List.of());
when(repository.countProjectPallets(any(), any())).thenReturn(0);
when(repository.loadActiveDeepWorkflowFloors(eq("W1"), any())).thenReturn(Set.of());
when(repository.insertDecision(any())).thenAnswer(invocation -> {
var value = invocation.getArgument(0, com.ruoyi.algorithm.repository.DecisionLogRecord.class);
return new DecisionLogEntry(value.decisionId(), value.warehouseId(), value.decisionType(),
value.inputVersion(), value.resultCode(), value.reason(), Instant.parse("2026-07-20T00:00:00Z"));
});
}
@Test
void repositoryExposesIncrementalLaneRefreshContract() {
assertThat(List.of(WarehouseRepository.class.getMethods()).stream()
.map(Method::getName))
.contains("refreshChangedLanes");
}
@Test
void inboundRefreshesChangedLanesBeforeReadingPlanningState() {
when(repository.pageCandidateLanes("W1", null, 100)).thenReturn(new LanePage(List.of(), null));
service.planInbound("W1", inbound("I-REFRESH"));
org.mockito.InOrder order = org.mockito.Mockito.inOrder(repository);
order.verify(repository).refreshChangedLanes("W1");
order.verify(repository).loadPolicy("W1");
}
@Test
void outboundRefreshesChangedLanesBeforeReadingPlanningState() {
OutboundRequest request = new OutboundRequest("O-REFRESH", null, "P", null,
"OFF", null, 1, true);
when(repository.searchOutboundStock("W1",
com.ruoyi.algorithm.repository.OutboundStockSearch.project("P", null))).thenReturn(List.of());
service.planOutbound("W1", request);
org.mockito.InOrder order = org.mockito.Mockito.inOrder(repository);
order.verify(repository).refreshChangedLanes("W1");
order.verify(repository).loadPolicy("W1");
}
@Test
void inboundDelegatesToPlannerLogsReadyAndNeverReservesDuringPlanning() {
VersionToken version = VersionToken.numeric(7);
LaneSnapshot lane = lane("L1", version, empty("L1", 1, 2), empty("L1", 2, 2));
when(repository.pageCandidateLanes("W1", null, 100)).thenReturn(new LanePage(List.of(
new LaneCandidate("W1", "L1", 2, 0, 2, false, false, version)), null));
when(repository.loadLaneSnapshots("W1", List.of("L1"))).thenReturn(List.of(lane));
when(repository.loadSummary("W1")).thenReturn(new com.ruoyi.algorithm.repository.WarehouseSummary(
"W1", 1, 2, 0, 2, 0, 0, 0));
InboundPlanningResult result = service.planInbound("W1", inbound("I-READY"));
assertThat(result.plan().assignment()).isPresent();
assertThat(result.laneVersion()).isEqualTo(version);
verify(repository, never()).tryReserveInbound(any());
verify(repository).insertDecision(any());
}
@Test
void inboundRejectionRemainsARejection() {
when(repository.pageCandidateLanes("W1", null, 100)).thenReturn(new LanePage(List.of(), null));
when(repository.loadSummary("W1")).thenReturn(new com.ruoyi.algorithm.repository.WarehouseSummary(
"W1", 0, 0, 0, 0, 0, 0, 0));
InboundPlanningResult result = service.planInbound("W1", inbound("I-REJECT"));
assertThat(result.plan().rejection()).isPresent();
assertThat(result.laneVersion()).isNull();
}
@Test
void inboundUsesBoundedProjectAggregateWhenExpectedSizeIsMissing() {
VersionToken v2 = VersionToken.numeric(2);
VersionToken v5 = VersionToken.numeric(5);
LaneSnapshot shallow = lane("L2", v2, empty("L2", 1, 2), empty("L2", 2, 2));
LaneSnapshot deep = lane("L5", v5, empty("L5", 1, 5), empty("L5", 2, 5), empty("L5", 3, 5),
empty("L5", 4, 5), empty("L5", 5, 5));
when(repository.countProjectPallets("W1", "P")).thenReturn(3);
when(repository.loadSummary("W1")).thenReturn(new com.ruoyi.algorithm.repository.WarehouseSummary(
"W1", 2, 7, 3, 7, 0, 0, 0));
when(repository.pageCandidateLanes("W1", null, 100)).thenReturn(new LanePage(List.of(
new LaneCandidate("W1", "L2", 2, 0, 2, false, false, v2),
new LaneCandidate("W1", "L5", 5, 0, 5, false, false, v5)), null));
when(repository.loadLaneSnapshots("W1", List.of("L2", "L5"))).thenReturn(List.of(shallow, deep));
InboundRequest request = new InboundRequest("I-SCALE", "T", "P", "SKU", "L", null,
1, null, Set.of(), BatchLaneMode.OPEN);
assertThat(service.planInbound("W1", request).plan().assignment().orElseThrow().laneCode()).isEqualTo("L5");
}
@Test
void inboundRetainsOnlyBoundedPageWinnersAndAValidLaterPageCandidateWins() throws Exception {
VersionToken firstVersion = VersionToken.numeric(21);
VersionToken laterVersion = VersionToken.numeric(22);
LaneSnapshot first = lane("A-LANE", firstVersion,
emptyAtFloor("A-LANE", 1, 2, 8), emptyAtFloor("A-LANE", 2, 2, 8));
LaneSnapshot later = lane("Z-LANE", laterVersion,
emptyAtFloor("Z-LANE", 1, 2, 1), emptyAtFloor("Z-LANE", 2, 2, 1));
when(repository.pageCandidateLanes("W1", null, 100)).thenReturn(new LanePage(List.of(
new LaneCandidate("W1", "A-LANE", 2, 0, 2, false, false, firstVersion)), "A-LANE"));
when(repository.pageCandidateLanes("W1", "A-LANE", 100)).thenReturn(new LanePage(List.of(
new LaneCandidate("W1", "Z-LANE", 2, 0, 2, false, false, laterVersion)), null));
when(repository.loadLaneSnapshots("W1", List.of("A-LANE"))).thenReturn(List.of(first));
when(repository.loadLaneSnapshots("W1", List.of("Z-LANE"))).thenReturn(List.of(later));
InboundPlanningResult result = service.planInbound("W1", inbound("I-PAGED"));
assertThat(result.plan().assignment().orElseThrow().laneCode()).isEqualTo("Z-LANE");
assertThat(result.laneVersion()).isEqualTo(laterVersion);
verify(repository).loadLaneSnapshots("W1", List.of("A-LANE"));
verify(repository).loadLaneSnapshots("W1", List.of("Z-LANE"));
String source = Files.readString(Path.of(
"src/main/java/com/ruoyi/algorithm/service/WarehouseApplicationService.java"));
int start = source.indexOf("public InboundPlanningResult planInbound");
int end = source.indexOf("public OutboundPlan planOutbound", start);
String inboundMethod = source.substring(start, end);
assertThat(inboundMethod).contains("startPaged");
assertThat(inboundMethod).doesNotContain("spaces.addAll", "List<SpaceState> spaces = new ArrayList");
}
@Test
void inboundRejectsOnlyAfterEveryCandidatePageWasEvaluated() {
VersionToken firstVersion = VersionToken.numeric(31);
VersionToken lastVersion = VersionToken.numeric(32);
LaneSnapshot first = lane("BAD-A", firstVersion,
state("BAD-A", 1, 1, SpaceStatus.DISABLED, null, null, null));
LaneSnapshot last = lane("BAD-Z", lastVersion,
state("BAD-Z", 1, 1, SpaceStatus.DISABLED, null, null, null));
when(repository.pageCandidateLanes("W1", null, 100)).thenReturn(new LanePage(List.of(
new LaneCandidate("W1", "BAD-A", 1, 0, 1, false, false, firstVersion)), "BAD-A"));
when(repository.pageCandidateLanes("W1", "BAD-A", 100)).thenReturn(new LanePage(List.of(
new LaneCandidate("W1", "BAD-Z", 1, 0, 1, false, false, lastVersion)), null));
when(repository.loadLaneSnapshots("W1", List.of("BAD-A"))).thenReturn(List.of(first));
when(repository.loadLaneSnapshots("W1", List.of("BAD-Z"))).thenReturn(List.of(last));
InboundPlanningResult result = service.planInbound("W1", inbound("I-EXHAUSTIVE"));
assertThat(result.plan().rejection()).isPresent();
assertThat(result.laneVersion()).isNull();
verify(repository).pageCandidateLanes("W1", "BAD-A", 100);
verify(repository).loadLaneSnapshots("W1", List.of("BAD-Z"));
}
@Test
void resolvedPendingSourceExcludesOnlyItsExactLane() {
VersionToken version = VersionToken.numeric(4);
LaneSnapshot mixed = lane("MIX", version,
occupied("MIX", 1, 2, "T-RES", "RES", "SKU"), empty("MIX", 2, 2));
LaneSnapshot safe = lane("SAFE", VersionToken.numeric(5),
occupied("SAFE", 1, 2, "T-SAFE", "RES", "SKU"), empty("SAFE", 2, 2));
when(repository.loadSummary("W1")).thenReturn(new com.ruoyi.algorithm.repository.WarehouseSummary(
"W1", 2, 4, 2, 2, 1, 0, 0));
when(repository.pageCandidateLanes("W1", null, 100)).thenReturn(new LanePage(List.of(
new LaneCandidate("W1", "MIX", 2, 1, 1, false, false, version),
new LaneCandidate("W1", "SAFE", 2, 1, 1, false, false, VersionToken.numeric(5))), null));
when(repository.loadLaneSnapshots("W1", List.of("MIX", "SAFE"))).thenReturn(List.of(mixed, safe));
when(repository.loadPendingOutboundJobs("W1")).thenReturn(List.of(new com.ruoyi.algorithm.repository.PendingOutboundJob(
"J", "TASK", null, "RES", "SKU", "MIX-1", "W1", "MIX", "EXPLICIT", "3", 21, "2026")));
assertThat(service.planInbound("W1", inbound("I-PENDING")).plan().assignment().orElseThrow().laneCode())
.isEqualTo("SAFE");
}
@Test
void unresolvedPendingProjectScopeDoesNotExcludeUnrelatedLanes() {
VersionToken version = VersionToken.numeric(4);
LaneSnapshot mixed = lane("MIX", version,
occupied("MIX", 1, 2, "T-RES", "RES", "SKU"), empty("MIX", 2, 2));
when(repository.loadSummary("W1")).thenReturn(new com.ruoyi.algorithm.repository.WarehouseSummary(
"W1", 1, 2, 1, 1, 0, 0, 0));
when(repository.pageCandidateLanes("W1", null, 100)).thenReturn(new LanePage(List.of(
new LaneCandidate("W1", "MIX", 2, 1, 1, false, false, version)), null));
when(repository.loadLaneSnapshots("W1", List.of("MIX"))).thenReturn(List.of(mixed));
when(repository.loadPendingOutboundJobs("W1")).thenReturn(List.of(new com.ruoyi.algorithm.repository.PendingOutboundJob(
"J", "TASK", null, "RES", "SKU", null, "W1", null, "UNRESOLVED", "3", 21, "2026")));
assertThat(service.planInbound("W1", inbound("I-UNRESOLVED")).plan().assignment()).isPresent();
}
@Test
void outboundPreservesWaitingAndErrorStatuses() {
OutboundRequest request = new OutboundRequest("O-WAIT", "T-SOURCE", null, null,
null, null, 1, true);
LaneSnapshot blocked = lane("SRC", VersionToken.numeric(1),
occupied("SRC", 1, 2, "T-SOURCE", "P", "SKU"),
occupied("SRC", 2, 2, "T-BLOCK", "P", "SKU"));
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.exactTray("T-SOURCE")))
.thenReturn(List.of(new OutboundStockMatch("S1", "SRC-1", "SRC", 1,
"T-SOURCE", "P", "SKU", "L", "2026-01-01 00:00:00",
Instant.parse("2026-01-01T00:00:00Z"))));
when(repository.loadLaneSnapshots("W1", List.of("SRC"))).thenReturn(List.of(blocked));
when(repository.loadRelocationBuffers("W1", Set.of(1))).thenReturn(List.of());
assertThat(service.planOutbound("W1", request).status()).isEqualTo(OutboundPlanStatus.WAITING);
OutboundRequest missing = new OutboundRequest("O-ERR", "MISSING", null, null,
null, null, 1, true);
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.exactTray("MISSING")))
.thenReturn(List.of());
assertThat(service.planOutbound("W1", missing).status()).isEqualTo(OutboundPlanStatus.ERROR);
}
@Test
void outboundPreservesReadyStatus() {
OutboundRequest request = new OutboundRequest("O-READY", "T-READY", null, null,
null, null, 1, true);
LaneSnapshot source = lane("READY", VersionToken.numeric(2),
occupied("READY", 1, 1, "T-READY", "P", "SKU"));
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.exactTray("T-READY")))
.thenReturn(List.of(new OutboundStockMatch("S2", "READY-1", "READY", 1,
"T-READY", "P", "SKU", "L", "2026-01-01 00:00:00",
Instant.parse("2026-01-01T00:00:00Z"))));
when(repository.loadLaneSnapshots("W1", List.of("READY"))).thenReturn(List.of(source));
when(repository.loadRelocationBuffers("W1", Set.of(1))).thenReturn(List.of());
assertThat(service.planOutbound("W1", request).status()).isEqualTo(OutboundPlanStatus.READY);
}
@Test
void outboundDerivesCapacityModeFromWarehouseThresholds() {
OutboundRequest request = new OutboundRequest("O-CAP", "T-CAP", null, null,
null, null, 1, true);
LaneSnapshot source = lane("CAP", VersionToken.numeric(2),
occupied("CAP", 1, 1, "T-CAP", "P", "SKU"));
when(repository.loadSummary("W1")).thenReturn(new com.ruoyi.algorithm.repository.WarehouseSummary(
"W1", 615, 615, 600, 15, 0, 0, 0));
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.exactTray("T-CAP")))
.thenReturn(List.of(new OutboundStockMatch("S", "CAP-1", "CAP", 1,
"T-CAP", "P", "SKU", "L", "2026-01-01T00:00:00Z",
Instant.parse("2026-01-01T00:00:00Z"))));
when(repository.loadLaneSnapshots("W1", List.of("CAP"))).thenReturn(List.of(source));
when(repository.loadRelocationBuffers("W1", Set.of(1))).thenReturn(List.of());
assertThat(service.planOutbound("W1", request).assignment().orElseThrow().laneReleasing()).isTrue();
}
@Test
void outboundOtherOwnerDeepFloorWaitsEvenWhenBuffersAreSufficient() {
OutboundRequest request = new OutboundRequest("O-LOCK", "T-SOURCE", null, null,
null, null, 1, true);
LaneSnapshot blocked = lane("SRC", VersionToken.numeric(1),
occupied("SRC", 1, 2, "T-SOURCE", "P", "SKU"),
occupied("SRC", 2, 2, "T-BLOCK", "P", "SKU"));
SpaceSnapshot buffer = new SpaceSnapshot(new SpaceState("BUF", "CACHE", "BUF-L", 1, 1, 1, 1, 1, "1",
SpaceRole.RELOCATION_BUFFER, SpaceStatus.EMPTY, false, false, null, null, null, null,
null, null, false, null), List.of(), Set.of(), false, false, null);
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.exactTray("T-SOURCE")))
.thenReturn(List.of(new OutboundStockMatch("S1", "SRC-1", "SRC", 1,
"T-SOURCE", "P", "SKU", "L", "2026-01-01T00:00:00Z",
Instant.parse("2026-01-01T00:00:00Z"))));
when(repository.loadLaneSnapshots("W1", List.of("SRC"))).thenReturn(List.of(blocked));
when(repository.loadRelocationBuffers("W1", Set.of(1))).thenReturn(List.of(buffer));
when(repository.loadActiveDeepWorkflowFloors("W1", "O-LOCK")).thenReturn(Set.of(1));
assertThat(service.planOutbound("W1", request).status()).isEqualTo(OutboundPlanStatus.WAITING);
}
@Test
void outboundSelfOwnedDeepFloorDoesNotWait() {
OutboundRequest request = new OutboundRequest("O-SELF", "T-SOURCE", null, null,
null, null, 1, true);
LaneSnapshot blocked = lane("SRC", VersionToken.numeric(1),
occupied("SRC", 1, 2, "T-SOURCE", "P", "SKU"),
occupied("SRC", 2, 2, "T-BLOCK", "P", "SKU"));
SpaceSnapshot buffer = new SpaceSnapshot(new SpaceState("BUF", "CACHE", "BUF-L", 1, 1, 1, 1, 1, "1",
SpaceRole.RELOCATION_BUFFER, SpaceStatus.EMPTY, false, false, null, null, null, null,
null, null, false, null), List.of(), Set.of(), false, false, null);
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.exactTray("T-SOURCE")))
.thenReturn(List.of(new OutboundStockMatch("S1", "SRC-1", "SRC", 1,
"T-SOURCE", "P", "SKU", "L", "2026-01-01T00:00:00Z",
Instant.parse("2026-01-01T00:00:00Z"))));
when(repository.loadLaneSnapshots("W1", List.of("SRC"))).thenReturn(List.of(blocked));
when(repository.loadRelocationBuffers("W1", Set.of(1))).thenReturn(List.of(buffer));
when(repository.loadActiveDeepWorkflowFloors("W1", "O-SELF")).thenReturn(Set.of());
assertThat(service.planOutbound("W1", request).status()).isEqualTo(OutboundPlanStatus.READY);
verify(repository).loadActiveDeepWorkflowFloors("W1", "O-SELF");
}
@Test
void outboundMissingScopeRemainsPlannerErrorInsteadOfThrowing() {
OutboundRequest request = new OutboundRequest("O-SCOPE", null, null, null,
null, null, 1, true);
assertThat(service.planOutbound("W1", request).status()).isEqualTo(OutboundPlanStatus.ERROR);
}
@Test
void exactTrayFifoLoadsProjectScopeSoRankIsGlobal() {
OutboundRequest request = new OutboundRequest("O-FIFO", "T-NEW", null, null,
"STRICT", 0, 1, true);
OutboundStockMatch selected = new OutboundStockMatch("S-NEW", "NEW-1", "NEW", 1,
"T-NEW", "P", "SKU", "L2", "2026-01-02T00:00:00Z",
Instant.parse("2026-01-02T00:00:00Z"));
OutboundStockMatch oldest = new OutboundStockMatch("S-OLD", "OLD-1", "OLD", 1,
"T-OLD", "P", "SKU", "L1", "2026-01-01T00:00:00Z",
Instant.parse("2026-01-01T00:00:00Z"));
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.exactTray("T-NEW")))
.thenReturn(List.of(selected));
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.project("P", "SKU")))
.thenReturn(List.of(oldest, selected));
when(repository.loadLaneSnapshots("W1", List.of("NEW", "OLD"))).thenReturn(List.of(
lane("NEW", VersionToken.numeric(2), occupiedAt("NEW", "T-NEW", "P", "SKU", "2026-01-02T00:00:00Z")),
lane("OLD", VersionToken.numeric(3), occupiedAt("OLD", "T-OLD", "P", "SKU", "2026-01-01T00:00:00Z"))));
when(repository.loadRelocationBuffers("W1", Set.of(1))).thenReturn(List.of());
var plan = service.planOutbound("W1", request);
assertThat(plan.assignment().orElseThrow().fifo().fifoRank()).isEqualTo(2);
assertThat(plan.assignment().orElseThrow().fifo().fifoViolation()).isTrue();
}
@Test
void exactTrayScopeAggregatesNullAndValidDetailsBeforeProjectSearch() {
OutboundRequest request = new OutboundRequest("O-SCOPE-AGG", "T", null, null,
"STRICT", 0, 1, true);
OutboundStockMatch missing = new OutboundStockMatch("S0", "L-1", "L", 1,
"T", null, null, "L", "2026-01-01T00:00:00Z", Instant.parse("2026-01-01T00:00:00Z"));
OutboundStockMatch valid = new OutboundStockMatch("S1", "L-1", "L", 1,
"T", "P", "SKU", "L", "2026-01-01T00:00:00Z", Instant.parse("2026-01-01T00:00:00Z"));
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.exactTray("T")))
.thenReturn(List.of(missing, valid));
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.project("P", "SKU")))
.thenReturn(List.of(valid));
when(repository.loadLaneSnapshots("W1", List.of("L"))).thenReturn(List.of(lane("L", VersionToken.numeric(1),
occupiedAt("L", "T", "P", "SKU", "2026-01-01T00:00:00Z"))));
when(repository.loadRelocationBuffers("W1", Set.of(1))).thenReturn(List.of());
assertThat(service.planOutbound("W1", request).status()).isEqualTo(OutboundPlanStatus.READY);
verify(repository).searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.project("P", "SKU"));
}
@Test
void exactTrayAmbiguousProjectsDoNotInventFifoScope() {
OutboundRequest request = new OutboundRequest("O-AMB", "T", null, null,
"STRICT", 0, 1, true);
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.exactTray("T")))
.thenReturn(List.of(
new OutboundStockMatch("S1", "L-1", "L", 1, "T", "P1", "SKU", "L", "2026-01-01T00:00:00Z", Instant.parse("2026-01-01T00:00:00Z")),
new OutboundStockMatch("S2", "L-1", "L", 1, "T", "P2", "SKU", "L", "2026-01-01T00:00:00Z", Instant.parse("2026-01-01T00:00:00Z"))));
var plan = service.planOutbound("W1", request);
assertThat(plan.error().orElseThrow().code()).isEqualTo(OutboundErrorCode.AMBIGUOUS_TRAY);
verify(repository, never()).loadLaneSnapshots(any(), any());
}
@Test
void exactTrayInMultipleSpacesReturnsExplicitAmbiguityError() {
OutboundRequest request = new OutboundRequest("O-AMB-SPACE", "T", null, null,
null, null, 1, true);
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.exactTray("T")))
.thenReturn(List.of(
new OutboundStockMatch("S1", "L1-1", "L1", 1, "T", "P", "SKU", "L", "2026-01-01T00:00:00Z", Instant.parse("2026-01-01T00:00:00Z")),
new OutboundStockMatch("S2", "L2-1", "L2", 1, "T", "P", "SKU", "L", "2026-01-01T00:00:00Z", Instant.parse("2026-01-01T00:00:00Z"))));
var plan = service.planOutbound("W1", request);
assertThat(plan.error().orElseThrow().code()).isEqualTo(OutboundErrorCode.AMBIGUOUS_TRAY);
verify(repository, never()).loadLaneSnapshots(any(), any());
}
@Test
void exactTrayWithMultipleProductDetailsPlansTheWholePallet() {
OutboundRequest request = new OutboundRequest("O-AMB-PRODUCT", "T", null, null,
null, null, 1, true);
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.exactTray("T")))
.thenReturn(List.of(
new OutboundStockMatch("S1", "L-1", "L", 1, "T", "P", "SKU-1", "L", "2026-01-01T00:00:00Z", Instant.parse("2026-01-01T00:00:00Z")),
new OutboundStockMatch("S2", "L-1", "L", 1, "T", "P", "SKU-2", "L", "2026-01-01T00:00:00Z", Instant.parse("2026-01-01T00:00:00Z"))));
when(repository.loadLaneSnapshots("W1", List.of("L"))).thenReturn(List.of(lane("L", VersionToken.numeric(1),
occupiedAt("L", "T", "P", null, "2026-01-01T00:00:00Z"))));
when(repository.loadRelocationBuffers("W1", Set.of(1))).thenReturn(List.of());
var plan = service.planOutbound("W1", request);
assertThat(plan.status()).isEqualTo(OutboundPlanStatus.READY);
assertThat(plan.assignment().orElseThrow().trayNo()).isEqualTo("T");
}
@Test
void productFilterNormalizesOnlyRepositoryMatchedPallets() {
OutboundRequest request = new OutboundRequest("O-PRODUCT", null, "P", "SKU-2",
null, null, 1, true);
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.project("P", "SKU-2")))
.thenReturn(List.of(new OutboundStockMatch("S2", "L-1", "L", 1,
"T", "P", "SKU-2", "L2", "2026-01-02T00:00:00Z",
Instant.parse("2026-01-02T00:00:00Z"))));
when(repository.loadLaneSnapshots("W1", List.of("L"))).thenReturn(List.of(lane("L", VersionToken.numeric(1),
occupiedAt("L", "T", "P", null, "2026-01-01T00:00:00Z"))));
when(repository.loadRelocationBuffers("W1", Set.of(1))).thenReturn(List.of());
var plan = service.planOutbound("W1", request);
assertThat(plan.status()).isEqualTo(OutboundPlanStatus.READY);
assertThat(plan.assignment().orElseThrow().productId()).isEqualTo("SKU-2");
}
@Test
void exactMultiProductTrayFifoUsesWholeProjectScope() {
OutboundRequest request = new OutboundRequest("O-MULTI-FIFO", "T-NEW", null, null,
"STRICT", 0, 1, true);
List<OutboundStockMatch> exact = List.of(
new OutboundStockMatch("S-NEW-1", "NEW-1", "NEW", 1, "T-NEW", "P", "SKU-1", "L1",
"2026-01-02T00:00:00Z", Instant.parse("2026-01-02T00:00:00Z")),
new OutboundStockMatch("S-NEW-2", "NEW-1", "NEW", 1, "T-NEW", "P", "SKU-2", "L2",
"2026-01-02T00:00:00Z", Instant.parse("2026-01-02T00:00:00Z")));
OutboundStockMatch oldest = new OutboundStockMatch("S-OLD", "OLD-1", "OLD", 1,
"T-OLD", "P", "SKU-3", "L0", "2026-01-01T00:00:00Z",
Instant.parse("2026-01-01T00:00:00Z"));
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.exactTray("T-NEW")))
.thenReturn(exact);
when(repository.searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.project("P", null)))
.thenReturn(List.of(oldest, exact.get(0), exact.get(1)));
when(repository.loadLaneSnapshots("W1", List.of("NEW", "OLD"))).thenReturn(List.of(
lane("NEW", VersionToken.numeric(2), occupiedAt("NEW", "T-NEW", "P", null, "2026-01-02T00:00:00Z")),
lane("OLD", VersionToken.numeric(3), occupiedAt("OLD", "T-OLD", "P", null, "2026-01-01T00:00:00Z"))));
when(repository.loadRelocationBuffers("W1", Set.of(1))).thenReturn(List.of());
var plan = service.planOutbound("W1", request);
assertThat(plan.status()).isEqualTo(OutboundPlanStatus.READY);
assertThat(plan.assignment().orElseThrow().fifo().fifoRank()).isEqualTo(2);
verify(repository).searchOutboundStock("W1", com.ruoyi.algorithm.repository.OutboundStockSearch.project("P", null));
}
@Test
void exposesSummaryDiagnosticsAndTidyThroughPlainMethods() {
var summary = new com.ruoyi.algorithm.repository.WarehouseSummary("W1", 0, 0, 0, 0, 0, 0, 0);
when(repository.loadSummary("W1")).thenReturn(summary);
when(repository.pageWarehouseLanes("W1", null, 100)).thenReturn(new LanePage(List.of(), null));
assertThat(service.summary("W1")).isEqualTo(summary);
assertThat(service.diagnostics("W1").issues()).isEmpty();
assertThat(service.planTidy("W1", Instant.parse("2026-07-20T00:00:00Z")).candidates()).isEmpty();
}
@Test
void tidyUsesPendingProjectAndProductScope() {
VersionToken version = VersionToken.numeric(1);
LaneSnapshot source = lane("GUEST", version,
occupied("GUEST", 1, 3, "T-R", "R", "SKU-R"),
occupied("GUEST", 2, 3, "T-G", "G", "SKU-G"), empty("GUEST", 3, 3));
LaneSnapshot target = lane("HOME", version,
occupied("HOME", 1, 3, "T-H", "G", "SKU-G"), empty("HOME", 2, 3), empty("HOME", 3, 3));
when(repository.pageWarehouseLanes("W1", null, 100)).thenReturn(new LanePage(List.of(
new LaneCandidate("W1", "GUEST", 3, 2, 1, false, false, version),
new LaneCandidate("W1", "HOME", 3, 1, 2, false, false, version)), null));
when(repository.loadLaneSnapshots("W1", List.of("GUEST", "HOME"))).thenReturn(List.of(source, target));
when(repository.loadPendingOutboundJobs("W1")).thenReturn(List.of(new com.ruoyi.algorithm.repository.PendingOutboundJob(
"J", "T", null, "G", "SKU-G", null, "W1", null, "UNRESOLVED", "3", 21, "2026")));
assertThat(service.planTidy("W1", Instant.parse("2026-07-20T00:00:00Z")).candidates()).isEmpty();
}
@Test
void explicitReserveMapsSnapshotConflictToReplanRequired() {
VersionToken version = VersionToken.numeric(9);
LaneSnapshot lane = lane("L1", version, empty("L1", 1, 2), empty("L1", 2, 2));
InboundPlanningResult planned = new InboundPlanningResult(
new InboundPlanner().plan(inbound("I-RESERVE"), new com.ruoyi.algorithm.inbound.InboundContext(
lane.spaceStates(), RepositoryPolicy.defaults().inboundPolicy(), List.of(), 0)), version);
when(repository.tryReserveInbound(any())).thenReturn(ReservationResult.conflict("LANE_VERSION_MISMATCH", "R1"));
InboundReservationOutcome outcome = service.reserveInbound("R1", "W1", "P", planned);
assertThat(outcome.resultCode()).isEqualTo("REPLAN_REQUIRED");
verify(repository).tryReserveInbound(new InboundReservation("R1", "W1", "L1", "L1-1", "P", version));
}
private InboundRequest inbound(String id) {
return new InboundRequest(id, "T-IN", "P", "SKU", "L", 2, 1, null, Set.of(), BatchLaneMode.OPEN);
}
private LaneSnapshot lane(String code, VersionToken version, SpaceState... spaces) {
return new LaneSnapshot("W1", code, version, List.of(spaces).stream()
.map(space -> new SpaceSnapshot(space, List.of(), Set.of(), space.availableEmpty(), false, null)).toList());
}
private SpaceState empty(String lane, int depth, int capacity) {
return state(lane, depth, capacity, SpaceStatus.EMPTY, null, null, null);
}
private SpaceState emptyAtFloor(String lane, int depth, int capacity, int floor) {
SpaceState value = empty(lane, depth, capacity);
return new SpaceState(value.spaceId(), value.zoneCode(), value.laneCode(), value.depthOrder(),
value.laneCapacity(), floor, value.aisleNo(), value.groupCoding(), value.rowCode(), value.role(),
value.status(), value.outboundTaskActive(), value.inventoryProjectConflict(), value.trayNo(),
value.projectNo(), value.productId(), value.lotNo(), value.inboundAt(), value.outboundAt(),
value.reserved(), value.rowVersion());
}
private SpaceState occupied(String lane, int depth, int capacity, String tray, String project, String product) {
return state(lane, depth, capacity, SpaceStatus.OCCUPIED, tray, project, product);
}
private SpaceState occupiedAt(String lane, String tray, String project, String product, String inboundAt) {
SpaceState value = state(lane, 1, 1, SpaceStatus.OCCUPIED, tray, project, product);
return new SpaceState(value.spaceId(), value.zoneCode(), value.laneCode(), value.depthOrder(),
value.laneCapacity(), value.floorNo(), value.aisleNo(), value.groupCoding(), value.rowCode(),
value.role(), value.status(), value.outboundTaskActive(), value.inventoryProjectConflict(),
value.trayNo(), value.projectNo(), value.productId(), value.lotNo(), Instant.parse(inboundAt),
value.outboundAt(), value.reserved(), value.rowVersion());
}
private SpaceState state(String lane, int depth, int capacity, SpaceStatus status,
String tray, String project, String product) {
return new SpaceState(lane + "-" + depth, "A", lane, depth, capacity, 1, 1, depth, "R",
SpaceRole.STORAGE, status, false, false, tray, project, product, "L",
Instant.parse("2026-01-01T00:00:00Z"), null, false, null);
}
}

View File

@@ -0,0 +1,284 @@
package com.ruoyi.algorithm.tidy;
import com.ruoyi.algorithm.model.SpaceRole;
import com.ruoyi.algorithm.model.SpaceState;
import com.ruoyi.algorithm.model.SpaceStatus;
import com.ruoyi.algorithm.policy.SchedulingPolicy;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
class TidyPlannerTest {
private final TidyPlanner planner = new TidyPlanner();
@Test
void returnsGuestFromOutsideToInsideWithoutMutatingCurrentInventory() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("SHARED", 5,
stock(1, "HOST-1", "HOST", old()), stock(2, "HOST-2", "HOST", old()),
stock(3, "GUEST-3", "GUEST", old()), stock(4, "GUEST-4", "GUEST", old()), empty(5)));
spaces.addAll(lane("OWN", 5,
stock(1, "OWN-1", "GUEST", old()), empty(2), empty(3), empty(4), empty(5)));
TidyPlan result = plan(spaces, List.of(), null, SchedulingPolicy.ft546Defaults());
TidyCandidate candidate = result.candidates().stream()
.filter(item -> item.type() == TidyCandidateType.GUEST_RETURN).findFirst().orElseThrow();
assertThat(candidate.moves()).containsExactly(
new TidyMove("GUEST-4", "SHARED-4", "OWN-2"),
new TidyMove("GUEST-3", "SHARED-3", "OWN-3"));
assertThat(spaces.stream().filter(space -> space.laneCode().equals("SHARED"))
.map(SpaceState::trayNo)).containsExactly("HOST-1", "HOST-2", "GUEST-3", "GUEST-4", null);
}
@Test
void pendingTrayProtectsItsWholeSourceLaneFromTidy() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("SHARED", 5,
stock(1, "HOST-1", "HOST", old()), stock(2, "GUEST-2", "GUEST", old()),
empty(3), empty(4), empty(5)));
spaces.addAll(lane("OWN", 5,
stock(1, "OWN-1", "GUEST", old()), empty(2), empty(3), empty(4), empty(5)));
TidyPlan result = plan(spaces,
List.of(new TidyPendingOutbound(null, null, "HOST-1", null)), null,
SchedulingPolicy.ft546Defaults());
assertThat(result.candidates()).noneMatch(candidate -> candidate.type() == TidyCandidateType.GUEST_RETURN);
}
@Test
void downgradeMergeMovesOutermostFirstAndOnlyUsesStagnantCurrentStock() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("DEEP", 5,
stock(1, "D-1", "P", "2026-07-01T00:00:00Z"),
stock(2, "D-2", "P", "2026-07-01T01:00:00Z"), empty(3), empty(4), empty(5)));
spaces.addAll(lane("SHALLOW", 2, empty(1), empty(2)));
TidyPlan stale = plan(spaces, List.of(), Instant.parse("2026-07-10T00:00:00Z"),
SchedulingPolicy.ft546Defaults());
TidyPlan fresh = plan(spaces, List.of(), Instant.parse("2026-07-02T00:00:00Z"),
SchedulingPolicy.ft546Defaults());
TidyCandidate downgrade = stale.candidates().stream()
.filter(item -> item.type() == TidyCandidateType.DOWNGRADE_MERGE).findFirst().orElseThrow();
assertThat(downgrade.moves()).containsExactly(
new TidyMove("D-2", "DEEP-2", "SHALLOW-1"),
new TidyMove("D-1", "DEEP-1", "SHALLOW-2"));
assertThat(downgrade.releasesLaneCode()).contains("DEEP");
assertThat(fresh.candidates()).noneMatch(item -> item.type() == TidyCandidateType.DOWNGRADE_MERGE);
}
@Test
void onePalletDowngradePrefersFlexThenCacheSingleDepthWithoutRequiringDepthTwo() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("DEEP", 5,
stock(1, "D-1", "P", old()), empty(2), empty(3), empty(4), empty(5)));
spaces.addAll(lane("CACHE-POOL", 1, "CACHE", empty(1)));
spaces.addAll(lane("FLEX-POOL", 1, "FLEX", empty(1)));
TidyCandidate downgrade = plan(spaces, List.of(), Instant.parse("2026-07-10T00:00:00Z"),
SchedulingPolicy.ft546Defaults()).candidates().stream()
.filter(item -> item.type() == TidyCandidateType.DOWNGRADE_MERGE).findFirst().orElseThrow();
assertThat(downgrade.moves()).containsExactly(new TidyMove("D-1", "DEEP-1", "FLEX-POOL-1"));
assertThat(downgrade.releasesLaneCode()).contains("DEEP");
}
@Test
void onePalletDowngradeFallsBackToEmptyDepthTwoWhenNoSinglePoolIsAvailable() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("DEEP", 5,
stock(1, "D-1", "P", old()), empty(2), empty(3), empty(4), empty(5)));
spaces.addAll(lane("DOUBLE", 2, empty(1), empty(2)));
TidyCandidate downgrade = plan(spaces, List.of(), Instant.parse("2026-07-10T00:00:00Z"),
SchedulingPolicy.ft546Defaults()).candidates().stream()
.filter(item -> item.type() == TidyCandidateType.DOWNGRADE_MERGE).findFirst().orElseThrow();
assertThat(downgrade.moves()).containsExactly(new TidyMove("D-1", "DEEP-1", "DOUBLE-1"));
}
@Test
void consolidatesAccessibleShallowStockIntoADeepProjectLaneAndYieldsToPendingLane() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("DEEP", 5,
stock(1, "D-1", "P", old()), stock(2, "D-2", "P", old()), empty(3), empty(4), empty(5)));
spaces.addAll(lane("SHALLOW", 2,
stock(1, "S-1", "P", old()), stock(2, "S-2", "P", old())));
TidyCandidate consolidate = plan(spaces, List.of(), null, SchedulingPolicy.ft546Defaults())
.candidates().stream()
.filter(item -> item.type() == TidyCandidateType.CONSOLIDATE_INCUBATION).findFirst().orElseThrow();
assertThat(consolidate.moves()).containsExactly(
new TidyMove("S-2", "SHALLOW-2", "DEEP-3"),
new TidyMove("S-1", "SHALLOW-1", "DEEP-4"));
assertThat(consolidate.releasesLaneCode()).contains("SHALLOW");
TidyPlan yielded = plan(spaces, List.of(new TidyPendingOutbound(null, null, null, "DEEP")),
null, SchedulingPolicy.ft546Defaults());
assertThat(yielded.candidates())
.noneMatch(item -> item.type() == TidyCandidateType.CONSOLIDATE_INCUBATION);
}
@Test
void sortsCandidateTypesAndCapsTheResultAtPolicyMaximum() {
List<SpaceState> spaces = new ArrayList<>();
for (int index = 1; index <= 3; index++) {
spaces.addAll(lane("SHARED-" + index, 2,
stock(1, "H-" + index, "H" + index, old()),
stock(2, "G-" + index, "G" + index, old())));
spaces.addAll(lane("OWN-" + index, 2,
stock(1, "O-" + index, "G" + index, old()), empty(2)));
}
SchedulingPolicy capped = new SchedulingPolicy(0.55, 0.45, 564, 597, 615, 4, 2, 1);
TidyPlan result = plan(spaces, List.of(), null, capped);
assertThat(result.candidates()).hasSize(1);
assertThat(result.candidates().get(0).type()).isEqualTo(TidyCandidateType.GUEST_RETURN);
assertThat(result.diagnostics()).anyMatch(message -> message.contains("1"));
}
@Test
void laterCandidatesDoNotReuseALaneAlreadyChangedByAnEarlierCandidate() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("DEEP", 5,
stock(1, "D-1", "P", old()), stock(2, "D-2", "P", old()),
empty(3), empty(4), empty(5)));
spaces.addAll(lane("A-EMPTY", 2, empty(1), empty(2)));
spaces.addAll(lane("SHALLOW", 2,
stock(1, "S-1", "P", old()), stock(2, "S-2", "P", old())));
TidyPlan result = plan(spaces, List.of(), Instant.parse("2026-07-10T00:00:00Z"),
SchedulingPolicy.ft546Defaults());
assertThat(result.candidates()).anyMatch(item -> item.type() == TidyCandidateType.DOWNGRADE_MERGE);
assertThat(result.candidates()).noneMatch(item -> item.type() == TidyCandidateType.CONSOLIDATE_INCUBATION);
}
@Test
void guestReturnFallsBackToAnEmptyGradedLaneWhenNoResidentLaneExists() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("SHARED", 3,
stock(1, "HOST", "HOST", old()),
stock(2, "G-2", "GUEST", old()), stock(3, "G-3", "GUEST", old())));
spaces.addAll(lane("EMPTY-2", 2, empty(1), empty(2)));
spaces.addAll(lane("EMPTY-5", 5, empty(1), empty(2), empty(3), empty(4), empty(5)));
TidyCandidate candidate = plan(spaces, List.of(), null, SchedulingPolicy.ft546Defaults())
.candidates().stream()
.filter(item -> item.type() == TidyCandidateType.GUEST_RETURN).findFirst().orElseThrow();
assertThat(candidate.moves()).containsExactly(
new TidyMove("G-3", "SHARED-3", "EMPTY-5-1"),
new TidyMove("G-2", "SHARED-2", "EMPTY-5-2"));
}
@Test
void downgradeStalenessUsesNewestProjectStockAcrossTheWholeWarehouse() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("DEEP", 5,
stock(1, "D-1", "P", old()), stock(2, "D-2", "P", old()),
empty(3), empty(4), empty(5)));
spaces.addAll(lane("RECENT", 1,
stock(1, "RECENT-1", "P", "2026-07-09T00:00:00Z")));
spaces.addAll(lane("EMPTY", 2, empty(1), empty(2)));
TidyPlan result = plan(spaces, List.of(), Instant.parse("2026-07-10T00:00:00Z"),
SchedulingPolicy.ft546Defaults());
assertThat(result.candidates()).noneMatch(item -> item.type() == TidyCandidateType.DOWNGRADE_MERGE);
}
@Test
void guestReturnTargetLaneIsNotReusedAsAConsolidationSource() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("SHARED", 2,
stock(1, "HOST", "HOST", old()), stock(2, "GUEST", "P", old())));
spaces.addAll(lane("A-OWN-SHALLOW", 2,
stock(1, "OWN", "P", old()), empty(2)));
spaces.addAll(lane("Z-DEEP", 5,
stock(1, "DEEP-1", "P", old()), empty(2), empty(3), empty(4), empty(5)));
TidyPlan result = plan(spaces, List.of(), null, SchedulingPolicy.ft546Defaults());
TidyCandidate guest = result.candidates().stream()
.filter(item -> item.type() == TidyCandidateType.GUEST_RETURN).findFirst().orElseThrow();
assertThat(guest.moves()).containsExactly(new TidyMove("GUEST", "SHARED-2", "A-OWN-SHALLOW-2"));
assertThat(result.candidates()).noneMatch(item -> item.type() == TidyCandidateType.CONSOLIDATE_INCUBATION
&& item.moves().stream().anyMatch(move -> move.fromSpaceId().startsWith("A-OWN-SHALLOW-")));
}
@Test
void commonProjectsAreExcludedFromDowngradeAndConsolidation() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("COMMON-DEEP", 5,
stock(1, "C-D1", "COMMON-X", old()), stock(2, "C-D2", "COMMON-X", old()),
empty(3), empty(4), empty(5)));
spaces.addAll(lane("COMMON-SHALLOW", 2,
stock(1, "C-S1", "COMMON-X", old()), stock(2, "C-S2", "COMMON-X", old())));
spaces.addAll(lane("EMPTY", 2, empty(1), empty(2)));
TidyPlan result = planner.plan(new TidyRequest(spaces, List.of(),
Instant.parse("2026-07-10T00:00:00Z"), SchedulingPolicy.ft546Defaults(), Set.of("COMMON-X")));
assertThat(result.candidates()).noneMatch(item -> item.type() == TidyCandidateType.DOWNGRADE_MERGE);
assertThat(result.candidates()).noneMatch(item -> item.type() == TidyCandidateType.CONSOLIDATE_INCUBATION);
}
@Test
void ignoresOccupiedSpaceWithoutProjectWhenBuildingLaneSegments() {
List<SpaceState> spaces = new ArrayList<>();
spaces.addAll(lane("UNKNOWN", 2,
stock(1, "UNKNOWN-1", null, old()), empty(2)));
assertThatCode(() -> planner.plan(new TidyRequest(spaces, List.of(), null,
SchedulingPolicy.ft546Defaults(), Set.of())))
.doesNotThrowAnyException();
}
private TidyPlan plan(List<SpaceState> spaces, List<TidyPendingOutbound> pending,
Instant now, SchedulingPolicy policy) {
return planner.plan(new TidyRequest(spaces, pending, now, policy));
}
private List<SpaceState> lane(String code, int capacity, Slot... slots) {
return lane(code, capacity, "MAIN", slots);
}
private List<SpaceState> lane(String code, int capacity, String zone, Slot... slots) {
List<SpaceState> spaces = new ArrayList<>();
for (Slot slot : slots) {
spaces.add(new SpaceState(code + "-" + slot.depth(), zone, code, slot.depth(), capacity,
1, 6, slot.depth(), "1", SpaceRole.STORAGE, slot.status(), false, false,
slot.tray(), slot.project(), "M", "LOT",
slot.inboundTime() == null ? null : Instant.parse(slot.inboundTime()),
null, false, "rv"));
}
return spaces;
}
private Slot stock(int depth, String tray, String project, String time) {
return new Slot(depth, SpaceStatus.OCCUPIED, tray, project, time);
}
private Slot empty(int depth) {
return new Slot(depth, SpaceStatus.EMPTY, null, null, null);
}
private String old() {
return "2026-07-01T00:00:00Z";
}
private record Slot(int depth, SpaceStatus status, String tray, String project, String inboundTime) {
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
INSERT INTO BAS_CARGO_SPACE (CARGO_SPACE_ID, WAREHOUSE_ID, ROADWAY_ID, CARGO_AREA_ID, CARGO_SPACE_NAME, THE_PLATOON, THE_COLUMN, THE_FLOOR, STATUS, LOCKED, TRAY_CODE, GROUP_CODING_NO, GROUP_CODING) VALUES
('DENSE-1', 'W1', 'R1', 'A', 'Dense inner', 1, 1, 1, '1', '', NULL, 'DENSE-1', '1'),
('DENSE-2', 'W1', 'R1', 'A', 'Dense outer', 1, 2, 1, '1', '', NULL, 'DENSE-1', '2'),
('OUT-INNER', 'W1', 'R2', 'A', 'Outbound inner', 2, 1, 1, '2', '', 'TRAY-OUT', 'OUT-1', '1'),
('OUT-OUTER', 'W1', 'R2', 'A', 'Outbound outer', 2, 2, 1, '1', '', NULL, 'OUT-1', '2'),
('BUFFER-1', 'W1', 'R3', 'E', 'Same floor buffer', 3, 1, 1, '1', '', NULL, 'BUFFER-1', '1'),
('CONFLICT-1', 'W1', 'R4', 'A', 'Conflict space', 4, 1, 1, '2', '', 'TRAY-C1', 'CONFLICT-1', '1'),
('GAP-INNER', 'W1', 'R5', 'C', 'Temporary inner', 5, 1, 1, '5', '', NULL, 'GAP-1', '1'),
('GAP-OUTER', 'W1', 'R5', 'C', 'Temporary outer', 5, 2, 1, '2', '', 'TRAY-GAP', 'GAP-1', '2'),
('LOCKED-1', 'W1', 'R6', 'A', 'Locked', 6, 1, 1, '1', '1', NULL, 'LOCKED-1', '1'),
('INVALID-DEPTH', 'W1', 'R7', 'A', 'Invalid depth', 7, 1, 1, '1', '', NULL, 'INVALID-1', 'X'),
('AVAILABLE-1', 'W1', 'R8', 'A', 'Available inner', 8, 1, 1, '1', '', NULL, 'AVAILABLE-1', '1'),
('AVAILABLE-2', 'W1', 'R8', 'A', 'Available outer', 8, 2, 1, '1', '', NULL, 'AVAILABLE-1', '2');
INSERT INTO BAS_STOCK (STOCK_ID, CARGO_SPACE_ID, TRAY_CODE, SPARE4, PRODUCT_ID, LOT_NO, IN_DATE, QUANTITY, OUT_QUANTITY) VALUES
('S-OUT-1', 'OUT-INNER', 'TRAY-OUT', 'P-OUT', 'SKU-OUT', 'LOT-OUT', '2026-01-01 08:00:00', 10, 0),
('S-CONFLICT-1', 'CONFLICT-1', 'TRAY-C1', 'P-ONE', 'SKU-1', 'LOT-1', '2026-01-02 08:00:00', 10, 0),
('S-CONFLICT-2', 'CONFLICT-1', 'TRAY-C2', 'P-TWO', 'SKU-2', 'LOT-2', '2026-01-03 08:00:00', 10, 0),
('S-GAP-1', 'GAP-OUTER', 'TRAY-GAP', 'P-GAP', 'SKU-GAP', 'LOT-GAP', '2026-01-04 08:00:00', 10, 0);
INSERT INTO ALG_INVENTORY_ITEM_SNAPSHOT (
WAREHOUSE_ID, STOCK_ID, SPACE_ID, TRAY_CODE, PROJECT_NO, PRODUCT_NO, LOT_NO,
INBOUND_TIME_RAW, INBOUND_AT, QUANTITY, OUT_QUANTITY)
SELECT C.WAREHOUSE_ID, S.STOCK_ID, S.CARGO_SPACE_ID, S.TRAY_CODE, S.SPARE4, S.PRODUCT_ID,
S.LOT_NO, S.IN_DATE, ALG_TRY_TIMESTAMP(S.IN_DATE), S.QUANTITY, S.OUT_QUANTITY
FROM BAS_STOCK S
JOIN BAS_CARGO_SPACE C ON C.CARGO_SPACE_ID = S.CARGO_SPACE_ID;
INSERT INTO ALG_SPACE_INVENTORY_SUMMARY (
WAREHOUSE_ID, SPACE_ID, STOCK_DETAIL_COUNT, STOCK_TRAY_COUNT,
STOCK_PROJECT_COUNT, STOCK_PRODUCT_COUNT)
SELECT WAREHOUSE_ID, SPACE_ID, COUNT(*),
COUNT(DISTINCT NULLIF(TRIM(TRAY_CODE), '')),
COUNT(DISTINCT NULLIF(TRIM(PROJECT_NO), '')),
COUNT(DISTINCT NULLIF(TRIM(PRODUCT_NO), ''))
FROM ALG_INVENTORY_ITEM_SNAPSHOT
GROUP BY WAREHOUSE_ID, SPACE_ID;
INSERT INTO MYDO_JOB (JOB_ID, TASK_NO, TRAY_CODE, CARGO_SPACE_ID, TARGET_CARGO_SPACE_ID, WAREHOUSE_ID, STATUS, IN_OR_OUT, CREATE_TIME, FINISH_TIME) VALUES
('JOB-OUT-1', 'TASK-OUT-1', 'TRAY-OUT', 'OUT-INNER', NULL, 'W1', '3', 21, '2026-01-05 08:00:00', ''),
('JOB-IN-1', 'TASK-IN-1', NULL, NULL, 'DENSE-1', 'W1', '1', 1, '2026-01-05 09:00:00', '');
INSERT INTO ALG_SPACE_ROLE (SPACE_ID, ROLE) VALUES ('BUFFER-1', 'RELOCATION_BUFFER');
INSERT INTO ALG_SPACE_TOPOLOGY (WAREHOUSE_ID, SPACE_ID, LANE_CODE, LANE_DEPTH, BASE_ZONE) VALUES
('W1','DENSE-1','DENSE-1',2,'FLEX'),
('W1','DENSE-2','DENSE-1',2,'FLEX'),
('W1','OUT-INNER','OUT-1',2,'FLEX'),
('W1','OUT-OUTER','OUT-1',2,'FLEX'),
('W1','BUFFER-1','BUFFER-1',1,'CACHE'),
('W1','CONFLICT-1','CONFLICT-1',1,'CACHE'),
('W1','GAP-INNER','GAP-1',2,'FLEX'),
('W1','GAP-OUTER','GAP-1',2,'FLEX'),
('W1','LOCKED-1','LOCKED-1',1,'CACHE'),
('W1','INVALID-DEPTH','INVALID-1',1,'CACHE'),
('W1','AVAILABLE-1','AVAILABLE-1',2,'FLEX'),
('W1','AVAILABLE-2','AVAILABLE-1',2,'FLEX');
INSERT INTO ALG_LANE_STATE (WAREHOUSE_ID, LANE_CODE, SPACE_COUNT, OCCUPIED_COUNT, AVAILABLE_COUNT, PENDING_OUTBOUND, HAS_GAP, STATE_VERSION)
VALUES ('W1', 'DENSE-1', 2, 0, 1, FALSE, FALSE, 7);

View File

@@ -0,0 +1,348 @@
CREATE TABLE BAS_CARGO_SPACE (
CARGO_SPACE_ID VARCHAR(80) PRIMARY KEY,
WAREHOUSE_ID VARCHAR(80) NOT NULL,
ROADWAY_ID VARCHAR(80),
CARGO_AREA_ID VARCHAR(80),
CARGO_SPACE_NAME VARCHAR(80),
THE_PLATOON INT,
THE_COLUMN INT,
THE_FLOOR INT,
STATUS VARCHAR(5),
LOCKED VARCHAR(5),
TRAY_CODE VARCHAR(255),
GROUP_CODING_NO VARCHAR(80),
GROUP_CODING VARCHAR(80)
);
CREATE TABLE BAS_STOCK (
STOCK_ID VARCHAR(32) PRIMARY KEY,
CARGO_SPACE_ID VARCHAR(80),
TRAY_CODE VARCHAR(225),
SPARE4 VARCHAR(255),
PRODUCT_ID VARCHAR(255),
LOT_NO VARCHAR(80),
IN_DATE VARCHAR(80),
QUANTITY DECIMAL(30, 0),
OUT_QUANTITY DECIMAL(30, 0)
);
CREATE TABLE MYDO_JOB (
JOB_ID VARCHAR(80) PRIMARY KEY,
TASK_NO VARCHAR(80),
TRAY_CODE VARCHAR(225),
CARGO_SPACE_ID VARCHAR(80),
TARGET_CARGO_SPACE_ID VARCHAR(80),
WAREHOUSE_ID VARCHAR(80),
STATUS VARCHAR(255),
IN_OR_OUT INT,
CREATE_TIME VARCHAR(80),
FINISH_TIME VARCHAR(80)
);
CREATE TABLE ALG_WAREHOUSE_POLICY (
WAREHOUSE_ID VARCHAR(80) PRIMARY KEY,
TARGET_FILL_RATE DECIMAL(9, 4),
FIFO_TOLERANCE_HOURS INT,
UPDATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE ALG_PROJECT_POLICY (
PROJECT_NO VARCHAR(255) PRIMARY KEY,
IS_ISOLATED BOOLEAN NOT NULL DEFAULT FALSE,
IS_COMMON BOOLEAN NOT NULL DEFAULT FALSE,
ALLOW_MIXED BOOLEAN NOT NULL DEFAULT FALSE,
UPDATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE ALG_SPACE_ROLE (
SPACE_ID VARCHAR(80) PRIMARY KEY,
ROLE VARCHAR(40) NOT NULL,
UPDATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CHECK (ROLE IN ('STORAGE', 'RELOCATION_BUFFER', 'OUTBOUND_BUFFER', 'PROTECTED_CORE'))
);
CREATE TABLE ALG_SPACE_TOPOLOGY (
WAREHOUSE_ID VARCHAR(80) NOT NULL,
SPACE_ID VARCHAR(80) PRIMARY KEY,
LANE_CODE VARCHAR(80) NOT NULL,
LANE_DEPTH INT NOT NULL,
BASE_ZONE VARCHAR(20) NOT NULL,
REBUILT_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CHECK (BASE_ZONE IN ('CACHE', 'RESERVE', 'MAIN', 'FLEX'))
);
CREATE INDEX ALG_IX_SPACE_TOPOLOGY_WAREHOUSE_LANE ON ALG_SPACE_TOPOLOGY (WAREHOUSE_ID, LANE_CODE);
CREATE TABLE ALG_INVENTORY_ITEM_SNAPSHOT (
WAREHOUSE_ID VARCHAR(80) NOT NULL,
STOCK_ID VARCHAR(80) NOT NULL,
SPACE_ID VARCHAR(80) NOT NULL,
TRAY_CODE VARCHAR(255),
PROJECT_NO VARCHAR(255),
PRODUCT_NO VARCHAR(255),
LOT_NO VARCHAR(80),
INBOUND_TIME_RAW VARCHAR(80),
INBOUND_AT TIMESTAMP,
QUANTITY DECIMAL(38, 6),
OUT_QUANTITY DECIMAL(38, 6),
SYNCHRONIZED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (WAREHOUSE_ID, STOCK_ID)
);
CREATE INDEX ALG_IX_INVENTORY_ITEM_SNAPSHOT_SPACE
ON ALG_INVENTORY_ITEM_SNAPSHOT (WAREHOUSE_ID, SPACE_ID);
CREATE INDEX ALG_IX_INVENTORY_ITEM_SNAPSHOT_TRAY
ON ALG_INVENTORY_ITEM_SNAPSHOT (WAREHOUSE_ID, TRAY_CODE);
CREATE INDEX ALG_IX_INVENTORY_ITEM_SNAPSHOT_PROJECT_PRODUCT
ON ALG_INVENTORY_ITEM_SNAPSHOT (WAREHOUSE_ID, PROJECT_NO, PRODUCT_NO);
CREATE TABLE ALG_SPACE_INVENTORY_SUMMARY (
WAREHOUSE_ID VARCHAR(80) NOT NULL,
SPACE_ID VARCHAR(80) NOT NULL,
STOCK_DETAIL_COUNT INT NOT NULL,
STOCK_TRAY_COUNT INT NOT NULL,
STOCK_PROJECT_COUNT INT NOT NULL,
STOCK_PRODUCT_COUNT INT NOT NULL,
SYNCHRONIZED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (WAREHOUSE_ID, SPACE_ID)
);
CREATE TABLE ALG_LANE_STATE (
WAREHOUSE_ID VARCHAR(80) NOT NULL,
LANE_CODE VARCHAR(80) NOT NULL,
SPACE_COUNT INT NOT NULL DEFAULT 0,
OCCUPIED_COUNT INT NOT NULL DEFAULT 0,
AVAILABLE_COUNT INT NOT NULL DEFAULT 0,
PENDING_OUTBOUND BOOLEAN NOT NULL DEFAULT FALSE,
HAS_GAP BOOLEAN NOT NULL DEFAULT FALSE,
SEMANTIC_FINGERPRINT VARCHAR(64) NOT NULL DEFAULT '',
STATE_VERSION BIGINT NOT NULL DEFAULT 0,
REBUILT_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (WAREHOUSE_ID, LANE_CODE)
);
CREATE TABLE ALG_RESERVATION (
RESERVATION_ID VARCHAR(80) PRIMARY KEY,
WAREHOUSE_ID VARCHAR(80) NOT NULL,
LANE_CODE VARCHAR(80) NOT NULL,
SPACE_ID VARCHAR(80) NOT NULL,
PROJECT_NO VARCHAR(255),
STATUS VARCHAR(20) NOT NULL,
ACTIVE_SPACE_GUARD VARCHAR(200) GENERATED ALWAYS AS (CASE WHEN STATUS = 'ACTIVE' THEN 'A:' || SPACE_ID ELSE 'H:' || RESERVATION_ID END),
CREATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
RELEASED_AT TIMESTAMP,
CHECK (STATUS IN ('ACTIVE', 'RELEASED', 'EXPIRED'))
);
CREATE UNIQUE INDEX ALG_UQ_ACTIVE_RESERVATION_SPACE ON ALG_RESERVATION (ACTIVE_SPACE_GUARD);
CREATE TABLE ALG_DEEP_WORKFLOW_LOCK (
LOCK_ID VARCHAR(80) PRIMARY KEY,
REQUEST_ID VARCHAR(80) NOT NULL,
WAREHOUSE_ID VARCHAR(80) NOT NULL,
FLOOR_NO INT NOT NULL,
STATUS VARCHAR(20) NOT NULL,
ACTIVE_FLOOR_GUARD VARCHAR(200) GENERATED ALWAYS AS (
CASE WHEN STATUS = 'ACTIVE' THEN WAREHOUSE_ID || ':' || FLOOR_NO ELSE LOCK_ID END),
CREATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
RELEASED_AT TIMESTAMP,
CHECK (STATUS IN ('ACTIVE', 'RELEASED'))
);
CREATE UNIQUE INDEX ALG_UQ_ACTIVE_DEEP_WORKFLOW_FLOOR ON ALG_DEEP_WORKFLOW_LOCK (ACTIVE_FLOOR_GUARD);
CREATE TABLE ALG_DECISION_LOG (
DECISION_ID VARCHAR(80) PRIMARY KEY,
WAREHOUSE_ID VARCHAR(80),
DECISION_TYPE VARCHAR(40) NOT NULL,
INPUT_VERSION BIGINT,
RESULT_CODE VARCHAR(80),
REASON VARCHAR(2000),
CREATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE ALIAS ALG_TRY_TIMESTAMP AS $$
java.sql.Timestamp algTryTimestamp(String value) {
if (value == null) return null;
try {
java.time.format.DateTimeFormatter formatter = new java.time.format.DateTimeFormatterBuilder()
.appendPattern("uuuu-MM-dd HH:mm:ss")
.optionalStart().appendFraction(java.time.temporal.ChronoField.NANO_OF_SECOND, 0, 9, true).optionalEnd()
.toFormatter().withResolverStyle(java.time.format.ResolverStyle.STRICT);
return java.sql.Timestamp.valueOf(java.time.LocalDateTime.parse(value, formatter));
} catch (RuntimeException exception) {
return null;
}
}
$$;
CREATE VIEW ALG_V_INVENTORY_ITEM AS
SELECT
STOCK_ID, SPACE_ID, TRAY_CODE, PROJECT_NO, PRODUCT_NO, LOT_NO,
INBOUND_TIME_RAW, INBOUND_AT, QUANTITY, OUT_QUANTITY
FROM ALG_INVENTORY_ITEM_SNAPSHOT;
CREATE VIEW ALG_V_ACTIVE_JOB AS
WITH TRAY_CANDIDATES AS (
SELECT J.JOB_ID,
COUNT(DISTINCT S.SPACE_ID) AS SPACE_COUNT,
MIN(S.SPACE_ID) AS CARGO_SPACE_ID,
COUNT(DISTINCT NULLIF(TRIM(S.PROJECT_NO), '')) AS PROJECT_COUNT,
MIN(NULLIF(TRIM(S.PROJECT_NO), '')) AS PROJECT_NO,
COUNT(DISTINCT NULLIF(TRIM(S.PRODUCT_NO), '')) AS PRODUCT_COUNT,
MIN(NULLIF(TRIM(S.PRODUCT_NO), '')) AS PRODUCT_NO
FROM MYDO_JOB J
JOIN ALG_INVENTORY_ITEM_SNAPSHOT S ON S.WAREHOUSE_ID = J.WAREHOUSE_ID
AND S.TRAY_CODE = NULLIF(TRIM(J.TRAY_CODE), '')
GROUP BY J.JOB_ID
),
RESOLVED_JOBS AS (
SELECT J.JOB_ID, J.TASK_NO, J.TRAY_CODE, J.TARGET_CARGO_SPACE_ID, J.WAREHOUSE_ID,
J.STATUS, J.IN_OR_OUT, J.CREATE_TIME AS CREATE_TIME_RAW,
COALESCE(SOURCE_SPACE.CARGO_SPACE_ID,
CASE WHEN TRAY_CANDIDATES.SPACE_COUNT = 1 THEN TRAY_CANDIDATES.CARGO_SPACE_ID END) AS SOURCE_SPACE_ID,
CASE WHEN SOURCE_SPACE.CARGO_SPACE_ID IS NOT NULL THEN 'EXPLICIT'
WHEN TRAY_CANDIDATES.SPACE_COUNT = 1 THEN 'TRAY'
WHEN TRAY_CANDIDATES.SPACE_COUNT > 1 THEN 'AMBIGUOUS_TRAY'
ELSE 'UNRESOLVED' END AS SOURCE_RESOLUTION
FROM MYDO_JOB J
LEFT JOIN BAS_CARGO_SPACE SOURCE_SPACE ON SOURCE_SPACE.CARGO_SPACE_ID = NULLIF(TRIM(J.CARGO_SPACE_ID), '')
AND SOURCE_SPACE.WAREHOUSE_ID = J.WAREHOUSE_ID
LEFT JOIN TRAY_CANDIDATES ON TRAY_CANDIDATES.JOB_ID = J.JOB_ID
WHERE J.FINISH_TIME IS NULL OR TRIM(J.FINISH_TIME) = ''
),
SOURCE_SCOPE AS (
SELECT R.JOB_ID,
COUNT(DISTINCT NULLIF(TRIM(S.PROJECT_NO), '')) AS PROJECT_COUNT,
MIN(NULLIF(TRIM(S.PROJECT_NO), '')) AS PROJECT_NO,
COUNT(DISTINCT NULLIF(TRIM(S.PRODUCT_NO), '')) AS PRODUCT_COUNT,
MIN(NULLIF(TRIM(S.PRODUCT_NO), '')) AS PRODUCT_NO
FROM RESOLVED_JOBS R
JOIN ALG_INVENTORY_ITEM_SNAPSHOT S ON S.WAREHOUSE_ID = R.WAREHOUSE_ID
AND S.SPACE_ID = R.SOURCE_SPACE_ID
GROUP BY R.JOB_ID
)
SELECT R.JOB_ID, R.TASK_NO, R.TRAY_CODE,
CASE WHEN R.SOURCE_SPACE_ID IS NOT NULL
THEN CASE WHEN SOURCE_SCOPE.PROJECT_COUNT = 1 THEN SOURCE_SCOPE.PROJECT_NO END
ELSE CASE WHEN TRAY_CANDIDATES.PROJECT_COUNT = 1 THEN TRAY_CANDIDATES.PROJECT_NO END END AS PROJECT_NO,
CASE WHEN R.SOURCE_SPACE_ID IS NOT NULL
THEN CASE WHEN SOURCE_SCOPE.PRODUCT_COUNT = 1 THEN SOURCE_SCOPE.PRODUCT_NO END
ELSE CASE WHEN TRAY_CANDIDATES.PRODUCT_COUNT = 1 THEN TRAY_CANDIDATES.PRODUCT_NO END END AS PRODUCT_NO,
R.SOURCE_SPACE_ID, R.TARGET_CARGO_SPACE_ID, R.WAREHOUSE_ID, R.STATUS, R.IN_OR_OUT,
R.CREATE_TIME_RAW, RESOLVED_SPACE.GROUP_CODING_NO AS SOURCE_LANE_CODE, R.SOURCE_RESOLUTION
FROM RESOLVED_JOBS R
LEFT JOIN TRAY_CANDIDATES ON TRAY_CANDIDATES.JOB_ID = R.JOB_ID
LEFT JOIN SOURCE_SCOPE ON SOURCE_SCOPE.JOB_ID = R.JOB_ID
LEFT JOIN BAS_CARGO_SPACE RESOLVED_SPACE ON RESOLVED_SPACE.CARGO_SPACE_ID = R.SOURCE_SPACE_ID;
CREATE VIEW ALG_V_SPACE_STATE AS
WITH OUTBOUND_LANES AS (
SELECT DISTINCT WAREHOUSE_ID, SOURCE_LANE_CODE AS LANE_CODE
FROM ALG_V_ACTIVE_JOB
WHERE IN_OR_OUT >= 20 AND SOURCE_LANE_CODE IS NOT NULL
),
ACTIVE_RESERVATIONS AS (
SELECT SPACE_ID, MIN(RESERVATION_ID) AS RESERVATION_ID
FROM ALG_RESERVATION
WHERE STATUS = 'ACTIVE'
GROUP BY SPACE_ID
),
SPACE_DEPTH AS (
SELECT CARGO_SPACE_ID, WAREHOUSE_ID, GROUP_CODING_NO, STATUS,
CASE WHEN GROUP_CODING REGEXP '^[0-9]+$' THEN CAST(GROUP_CODING AS INT) END AS DEPTH
FROM BAS_CARGO_SPACE
),
LANE_GAPS AS (
SELECT DISTINCT INNER_SPACE.WAREHOUSE_ID, INNER_SPACE.GROUP_CODING_NO AS LANE_CODE
FROM BAS_CARGO_SPACE INNER_SPACE
JOIN BAS_CARGO_SPACE OUTER_SPACE
ON OUTER_SPACE.WAREHOUSE_ID = INNER_SPACE.WAREHOUSE_ID
AND OUTER_SPACE.GROUP_CODING_NO = INNER_SPACE.GROUP_CODING_NO
AND CASE WHEN OUTER_SPACE.GROUP_CODING REGEXP '^[0-9]+$' THEN CAST(OUTER_SPACE.GROUP_CODING AS INT) END
> CASE WHEN INNER_SPACE.GROUP_CODING REGEXP '^[0-9]+$' THEN CAST(INNER_SPACE.GROUP_CODING AS INT) END
WHERE CASE WHEN INNER_SPACE.GROUP_CODING REGEXP '^[0-9]+$' THEN CAST(INNER_SPACE.GROUP_CODING AS INT) END IS NOT NULL
AND CASE WHEN OUTER_SPACE.GROUP_CODING REGEXP '^[0-9]+$' THEN CAST(OUTER_SPACE.GROUP_CODING AS INT) END IS NOT NULL
AND NULLIF(TRIM(INNER_SPACE.TRAY_CODE), '') IS NULL
AND NOT EXISTS (
SELECT 1 FROM ALG_INVENTORY_ITEM_SNAPSHOT INNER_STOCK
WHERE INNER_STOCK.WAREHOUSE_ID = INNER_SPACE.WAREHOUSE_ID
AND INNER_STOCK.SPACE_ID = INNER_SPACE.CARGO_SPACE_ID
)
AND (
NULLIF(TRIM(OUTER_SPACE.TRAY_CODE), '') IS NOT NULL
OR EXISTS (
SELECT 1 FROM ALG_INVENTORY_ITEM_SNAPSHOT OUTER_STOCK
WHERE OUTER_STOCK.WAREHOUSE_ID = OUTER_SPACE.WAREHOUSE_ID
AND OUTER_STOCK.SPACE_ID = OUTER_SPACE.CARGO_SPACE_ID
)
)
)
SELECT
C.CARGO_SPACE_ID AS SPACE_ID,
C.WAREHOUSE_ID,
C.GROUP_CODING_NO AS LANE_CODE,
C.CARGO_AREA_ID AS AREA_ID,
CASE
WHEN COALESCE(R.ROLE, 'STORAGE') IN ('RELOCATION_BUFFER', 'OUTBOUND_BUFFER') THEN 'CACHE'
WHEN COALESCE(R.ROLE, 'STORAGE') = 'PROTECTED_CORE' THEN 'RESERVE'
ELSE TOPOLOGY.BASE_ZONE
END AS ZONE_CODE,
C.ROADWAY_ID,
C.CARGO_SPACE_NAME AS DISPLAY_NAME,
C.THE_PLATOON AS PLATOON,
C.THE_COLUMN AS COLUMN_NO,
C.THE_FLOOR AS FLOOR_NO,
C.GROUP_CODING AS DEPTH_RAW,
SPACE_DEPTH.DEPTH,
C.STATUS AS SPACE_STATUS,
C.LOCKED,
C.TRAY_CODE AS SPACE_TRAY_CODE,
COALESCE(R.ROLE, 'STORAGE') AS ROLE,
COALESCE(STOCK.STOCK_DETAIL_COUNT, 0) AS STOCK_DETAIL_COUNT,
COALESCE(STOCK.STOCK_TRAY_COUNT, 0) AS STOCK_TRAY_COUNT,
COALESCE(STOCK.STOCK_PROJECT_COUNT, 0) AS STOCK_PROJECT_COUNT,
COALESCE(STOCK.STOCK_PRODUCT_COUNT, 0) AS STOCK_PRODUCT_COUNT,
COALESCE(STOCK.STOCK_TRAY_COUNT, 0) > 1 AS TRAY_CONFLICT,
COALESCE(STOCK.STOCK_PROJECT_COUNT, 0) > 1 AS PROJECT_CONFLICT,
FALSE AS PRODUCT_CONFLICT,
OUTBOUND_LANES.LANE_CODE IS NOT NULL AS PENDING_OUTBOUND,
ACTIVE_RESERVATIONS.RESERVATION_ID,
ACTIVE_RESERVATIONS.RESERVATION_ID IS NOT NULL AS RESERVED,
LANE_GAPS.LANE_CODE IS NOT NULL AS HAS_GAP,
CASE
WHEN C.STATUS IS NULL OR C.STATUS <> '1' THEN FALSE
WHEN NULLIF(TRIM(C.LOCKED), '') IS NOT NULL AND TRIM(C.LOCKED) NOT IN ('0', 'N') THEN FALSE
WHEN NULLIF(TRIM(C.TRAY_CODE), '') IS NOT NULL THEN FALSE
WHEN COALESCE(STOCK.STOCK_DETAIL_COUNT, 0) <> 0 THEN FALSE
WHEN SPACE_DEPTH.DEPTH IS NULL THEN FALSE
WHEN COALESCE(R.ROLE, 'STORAGE') <> 'STORAGE' THEN FALSE
WHEN OUTBOUND_LANES.LANE_CODE IS NOT NULL THEN FALSE
WHEN ACTIVE_RESERVATIONS.RESERVATION_ID IS NOT NULL THEN FALSE
WHEN LANE_GAPS.LANE_CODE IS NOT NULL THEN FALSE
WHEN EXISTS (
SELECT 1
FROM BAS_CARGO_SPACE INNER_CANDIDATE
LEFT JOIN ALG_SPACE_INVENTORY_SUMMARY INNER_STOCK
ON INNER_STOCK.WAREHOUSE_ID = INNER_CANDIDATE.WAREHOUSE_ID
AND INNER_STOCK.SPACE_ID = INNER_CANDIDATE.CARGO_SPACE_ID
WHERE INNER_CANDIDATE.WAREHOUSE_ID = C.WAREHOUSE_ID
AND INNER_CANDIDATE.GROUP_CODING_NO = C.GROUP_CODING_NO
AND CASE WHEN INNER_CANDIDATE.GROUP_CODING REGEXP '^[0-9]+$' THEN CAST(INNER_CANDIDATE.GROUP_CODING AS INT) END < SPACE_DEPTH.DEPTH
AND INNER_CANDIDATE.STATUS = '1'
AND NULLIF(TRIM(INNER_CANDIDATE.TRAY_CODE), '') IS NULL
AND INNER_STOCK.SPACE_ID IS NULL
) THEN FALSE
ELSE TRUE
END AS INBOUND_AVAILABLE
FROM BAS_CARGO_SPACE C
JOIN ALG_SPACE_TOPOLOGY TOPOLOGY ON TOPOLOGY.SPACE_ID = C.CARGO_SPACE_ID
LEFT JOIN SPACE_DEPTH ON SPACE_DEPTH.CARGO_SPACE_ID = C.CARGO_SPACE_ID
LEFT JOIN ALG_SPACE_INVENTORY_SUMMARY STOCK ON STOCK.WAREHOUSE_ID = C.WAREHOUSE_ID
AND STOCK.SPACE_ID = C.CARGO_SPACE_ID
LEFT JOIN ALG_SPACE_ROLE R ON R.SPACE_ID = C.CARGO_SPACE_ID
LEFT JOIN OUTBOUND_LANES ON OUTBOUND_LANES.WAREHOUSE_ID = C.WAREHOUSE_ID
AND OUTBOUND_LANES.LANE_CODE = C.GROUP_CODING_NO
LEFT JOIN ACTIVE_RESERVATIONS ON ACTIVE_RESERVATIONS.SPACE_ID = C.CARGO_SPACE_ID
LEFT JOIN LANE_GAPS ON LANE_GAPS.WAREHOUSE_ID = C.WAREHOUSE_ID
AND LANE_GAPS.LANE_CODE = C.GROUP_CODING_NO;