feat: add algorithm core for shadow observation
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 + "'");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
58
fortucky-algorithm/src/test/resources/db/h2/data.sql
Normal file
58
fortucky-algorithm/src/test/resources/db/h2/data.sql
Normal 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);
|
||||
348
fortucky-algorithm/src/test/resources/db/h2/schema.sql
Normal file
348
fortucky-algorithm/src/test/resources/db/h2/schema.sql
Normal 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;
|
||||
Reference in New Issue
Block a user