diff --git a/fortucky-algorithm/pom.xml b/fortucky-algorithm/pom.xml
new file mode 100644
index 0000000..ff098ee
--- /dev/null
+++ b/fortucky-algorithm/pom.xml
@@ -0,0 +1,48 @@
+
+
+ 4.0.0
+
+
+ com.ruoyi
+ ruoyi
+ 3.9.0
+
+
+ fortucky-algorithm
+ jar
+ FT546 密集库 Java 调度算法与 WMS 接入门面
+
+
+
+ org.springframework
+ spring-jdbc
+
+
+ org.springframework.boot
+ spring-boot-autoconfigure
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ com.h2database
+ h2
+ test
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ test
+
+
+
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/LaneAnalyzer.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/LaneAnalyzer.java
new file mode 100644
index 0000000..6aa4ddc
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/LaneAnalyzer.java
@@ -0,0 +1,79 @@
+package com.ruoyi.algorithm;
+
+import com.ruoyi.algorithm.model.LaneState;
+import com.ruoyi.algorithm.model.SpaceState;
+import com.ruoyi.algorithm.model.SpaceStatus;
+import com.ruoyi.algorithm.policy.SchedulingPolicy;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+
+public final class LaneAnalyzer {
+
+ public LaneState analyze(List laneSpaces, SchedulingPolicy policy) {
+ List spaces = laneSpaces.stream()
+ .sorted(Comparator.comparingInt(SpaceState::depthOrder))
+ .toList();
+
+ boolean emptySeen = false;
+ boolean physicallyContinuous = true;
+ boolean invalidInventoryState = false;
+ int segmentCount = 0;
+ String currentSegmentKey = null;
+ String residentProject = null;
+ String guestProject = null;
+
+ for (SpaceState space : spaces) {
+ boolean occupied = space.occupied();
+ if (!occupied) {
+ emptySeen = true;
+ continue;
+ }
+
+ if (emptySeen) {
+ physicallyContinuous = false;
+ }
+ if (space.inventoryProjectConflict()
+ || (space.status() == SpaceStatus.EMPTY && space.hasTray())) {
+ invalidInventoryState = true;
+ }
+
+ String segmentKey = space.projectNo() == null ? "__UNKNOWN__" : space.projectNo();
+ if (!segmentKey.equals(currentSegmentKey)) {
+ currentSegmentKey = segmentKey;
+ segmentCount++;
+ if (segmentCount == 1) {
+ residentProject = space.projectNo();
+ } else if (segmentCount == 2) {
+ guestProject = space.projectNo();
+ }
+ }
+ }
+
+ boolean corrupt = invalidInventoryState || !physicallyContinuous || segmentCount > 2;
+ Optional nextSlot = corrupt
+ ? Optional.empty()
+ : spaces.stream().filter(SpaceState::availableEmpty).findFirst();
+ int outermostOccupiedDepth = spaces.stream()
+ .filter(SpaceState::occupied)
+ .mapToInt(SpaceState::depthOrder)
+ .max()
+ .orElse(0);
+ Optional outerSlot = spaces.stream()
+ .filter(SpaceState::availableEmpty)
+ .filter(space -> space.depthOrder() > outermostOccupiedDepth)
+ .findFirst();
+
+ return new LaneState(
+ spaces,
+ physicallyContinuous,
+ corrupt,
+ segmentCount,
+ Optional.ofNullable(residentProject),
+ Optional.ofNullable(guestProject),
+ nextSlot,
+ outerSlot
+ );
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/BatchLaneMode.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/BatchLaneMode.java
new file mode 100644
index 0000000..2364e23
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/BatchLaneMode.java
@@ -0,0 +1,6 @@
+package com.ruoyi.algorithm.inbound;
+
+public enum BatchLaneMode {
+ OPEN,
+ SEALED
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundAssignment.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundAssignment.java
new file mode 100644
index 0000000..e3c810a
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundAssignment.java
@@ -0,0 +1,20 @@
+package com.ruoyi.algorithm.inbound;
+
+public record InboundAssignment(
+ String requestId,
+ String spaceId,
+ String laneCode,
+ int laneCapacity,
+ int depthOrder,
+ InboundLadderRung ladderRung,
+ InboundBorrowType borrowType,
+ String mixedIntoResidentProject,
+ double capacityScore,
+ double efficiencyScore,
+ double totalScore,
+ double fillRatePercent,
+ boolean batchLaneApplied,
+ boolean openedNewLane,
+ String reason
+) {
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundBorrowType.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundBorrowType.java
new file mode 100644
index 0000000..4cc1233
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundBorrowType.java
@@ -0,0 +1,8 @@
+package com.ruoyi.algorithm.inbound;
+
+public enum InboundBorrowType {
+ NONE,
+ CACHE_BORROWED,
+ RESERVE_BORROWED,
+ MIXED_IN
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundContext.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundContext.java
new file mode 100644
index 0000000..6e042e6
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundContext.java
@@ -0,0 +1,19 @@
+package com.ruoyi.algorithm.inbound;
+
+import com.ruoyi.algorithm.model.SpaceState;
+
+import java.util.List;
+
+public record InboundContext(
+ List spaces,
+ InboundPolicy policy,
+ List pendingOutbounds,
+ int occupiedCount
+) {
+ public InboundContext {
+ spaces = spaces == null ? List.of() : List.copyOf(spaces);
+ policy = policy == null ? InboundPolicy.defaults() : policy;
+ pendingOutbounds = pendingOutbounds == null ? List.of() : List.copyOf(pendingOutbounds);
+ if (occupiedCount < 0) throw new IllegalArgumentException("occupiedCount cannot be negative");
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundLadderRung.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundLadderRung.java
new file mode 100644
index 0000000..4834279
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundLadderRung.java
@@ -0,0 +1,12 @@
+package com.ruoyi.algorithm.inbound;
+
+public enum InboundLadderRung {
+ CONTINUE,
+ OPEN_GRADED,
+ OPEN_ADJACENT,
+ MIXED_GUEST,
+ POOL_BORROW,
+ COMMON_SINGLE,
+ COMMON_LANE_CONTINUE,
+ COMMON_LANE_OPEN
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundPlan.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundPlan.java
new file mode 100644
index 0000000..3df424c
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundPlan.java
@@ -0,0 +1,21 @@
+package com.ruoyi.algorithm.inbound;
+
+import java.util.Optional;
+
+public record InboundPlan(Optional assignment, Optional rejection) {
+ public InboundPlan {
+ assignment = assignment == null ? Optional.empty() : assignment;
+ rejection = rejection == null ? Optional.empty() : rejection;
+ if (assignment.isPresent() == rejection.isPresent()) {
+ throw new IllegalArgumentException("plan must contain exactly one assignment or rejection");
+ }
+ }
+
+ public static InboundPlan assigned(InboundAssignment assignment) {
+ return new InboundPlan(Optional.of(assignment), Optional.empty());
+ }
+
+ public static InboundPlan rejected(InboundRejection rejection) {
+ return new InboundPlan(Optional.empty(), Optional.of(rejection));
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundPlanner.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundPlanner.java
new file mode 100644
index 0000000..51edcb4
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundPlanner.java
@@ -0,0 +1,674 @@
+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 java.time.Instant;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+public final class InboundPlanner {
+ private static final Map> ADJACENT_DEPTH_ORDER = Map.of(
+ 2, List.of(3, 5),
+ 3, List.of(5, 2),
+ 5, List.of(3, 2)
+ );
+
+ public InboundPlan plan(InboundRequest request, InboundContext context) {
+ Objects.requireNonNull(request, "request");
+ Objects.requireNonNull(context, "context");
+ validateManualReservations(context);
+ List lanes = buildLanes(context);
+
+ Optional selected = context.policy().isCommon(request.projectNo())
+ ? chooseCommon(request, context, lanes)
+ : chooseProject(request, context, lanes);
+ if (selected.isPresent()) {
+ return InboundPlan.assigned(toAssignment(request, selected.orElseThrow()));
+ }
+
+ List trace = traceFor(request, context, lanes);
+ boolean sealedProjectRequest = request.batchLaneMode() == BatchLaneMode.SEALED
+ && !context.policy().isCommon(request.projectNo());
+ InboundRejectionCode code = sealedProjectRequest
+ ? InboundRejectionCode.NO_BATCH_COMPATIBLE_LANE
+ : InboundRejectionCode.NO_FEASIBLE_LOCATION;
+ String reason = sealedProjectRequest
+ ? "No lane can continue the same project, product and lot, and no completely empty lane remains."
+ : "Inbound admission ladder exhausted every legal location; no feasible location remains.";
+ return InboundPlan.rejected(new InboundRejection(
+ code, reason, request.projectNo(), request.productId(), request.lotNo(), trace));
+ }
+
+ public PagedSession startPaged(InboundRequest request, InboundPolicy policy,
+ List pendingOutbounds, int occupiedCount) {
+ return new PagedSession(request, policy, pendingOutbounds, occupiedCount);
+ }
+
+ public final class PagedSession {
+ private final InboundRequest request;
+ private final InboundPolicy policy;
+ private final List pendingOutbounds;
+ private final int occupiedCount;
+ private final Map retained = new LinkedHashMap<>();
+ private final java.util.Set missingManualLanes;
+ private long freeSinglePool;
+
+ private PagedSession(InboundRequest request, InboundPolicy policy,
+ List pendingOutbounds, int occupiedCount) {
+ this.request = Objects.requireNonNull(request, "request");
+ this.policy = Objects.requireNonNull(policy, "policy");
+ this.pendingOutbounds = List.copyOf(pendingOutbounds == null ? List.of() : pendingOutbounds);
+ this.occupiedCount = occupiedCount;
+ this.missingManualLanes = policy.manualReservedLanes().stream()
+ .map(ManualLaneReservation::laneCode)
+ .collect(Collectors.toCollection(java.util.LinkedHashSet::new));
+ }
+
+ public void accept(List pageSpaces) {
+ InboundContext context = new InboundContext(pageSpaces, policy, pendingOutbounds, occupiedCount);
+ List lanes = buildLanes(context);
+ lanes.forEach(lane -> missingManualLanes.remove(lane.code()));
+ if (policy.isCommon(request.projectNo())) {
+ acceptCommon(context, lanes);
+ } else {
+ acceptProject(context, lanes);
+ }
+ }
+
+ public Optional currentAssignment() {
+ return selected().map(candidate -> toAssignment(request, candidate));
+ }
+
+ public java.util.Set retainedLaneCodes() {
+ return retained.values().stream().map(candidate -> candidate.lane().code())
+ .collect(Collectors.toUnmodifiableSet());
+ }
+
+ public InboundPlan finish() {
+ if (!missingManualLanes.isEmpty()) {
+ throw new IllegalArgumentException("manualReservedLanes references unknown lane "
+ + missingManualLanes.iterator().next());
+ }
+ Optional candidate = selected();
+ if (candidate.isPresent()) return InboundPlan.assigned(toAssignment(request, candidate.orElseThrow()));
+
+ boolean sealed = request.batchLaneMode() == BatchLaneMode.SEALED
+ && !policy.isCommon(request.projectNo());
+ InboundRejectionCode code = sealed ? InboundRejectionCode.NO_BATCH_COMPATIBLE_LANE
+ : InboundRejectionCode.NO_FEASIBLE_LOCATION;
+ String reason = sealed
+ ? "No lane can continue the same project, product and lot, and no completely empty lane remains."
+ : "Inbound admission ladder exhausted every legal location; no feasible location remains.";
+ return InboundPlan.rejected(new InboundRejection(code, reason, request.projectNo(),
+ request.productId(), request.lotNo(), pagedTrace(sealed)));
+ }
+
+ private void acceptProject(InboundContext context, List lanes) {
+ retain("CONTINUE", best(lanes.stream()
+ .filter(lane -> lane.usableForInbound() && !lane.occupied().isEmpty())
+ .map(lane -> continueCandidate(request, context, lane)).flatMap(Optional::stream).toList()),
+ candidateComparator());
+
+ int targetDepth = gradedTargetDepth(request, context);
+ retain("GRADED", best(emptyLaneCandidates(request, context, lanes, targetDepth,
+ InboundLadderRung.OPEN_GRADED)), candidateComparator());
+ for (int depth : ADJACENT_DEPTH_ORDER.getOrDefault(targetDepth, List.of())) {
+ retain("ADJACENT:" + depth, best(emptyLaneCandidates(request, context, lanes, depth,
+ InboundLadderRung.OPEN_ADJACENT)), candidateComparator());
+ }
+
+ if (request.batchLaneMode() != BatchLaneMode.SEALED && request.projectNo() != null
+ && !policy.isIsolated(request.projectNo()) && !policy.isCommon(request.projectNo())
+ && policy.allowsMixed(request.projectNo())) {
+ Comparator mixedOrder = Comparator.comparingInt(Candidate::remaining)
+ .thenComparing(Candidate::residentNewest).thenComparing(candidateComparator());
+ retain("MIXED", bestMixed(request, context, lanes), mixedOrder);
+ }
+ acceptPool(context, lanes);
+ }
+
+ private void acceptPool(InboundContext context, List lanes) {
+ List pool = lanes.stream().filter(lane -> lane.depth() == 1)
+ .filter(lane -> lane.spaces().stream().allMatch(space -> space.role() == SpaceRole.STORAGE))
+ .toList();
+ freeSinglePool += pool.stream().filter(Lane::isEmptyLane).count();
+ List tiers = new ArrayList<>();
+ tiers.add(new PoolTier("FLEX", InboundBorrowType.NONE));
+ tiers.add(new PoolTier("CACHE", InboundBorrowType.CACHE_BORROWED));
+ if (occupiedCount >= policy.schedulingPolicy().capacityFirstOccupied()) {
+ tiers.add(new PoolTier("RESERVE", InboundBorrowType.RESERVE_BORROWED));
+ }
+ for (PoolTier tier : tiers) {
+ retain("POOL:" + tier.zone(), best(pool.stream().filter(Lane::usableForInbound)
+ .filter(Lane::isEmptyLane)
+ .filter(lane -> tier.zone().equalsIgnoreCase(lane.spaces().get(0).zoneCode()))
+ .filter(lane -> usableSlot(lane, lane.spaces().get(0), request, context))
+ .map(lane -> score(lane, lane.spaces().get(0), request, context,
+ InboundLadderRung.POOL_BORROW, tier.borrowType(), null, true)).toList()),
+ candidateComparator());
+ }
+ }
+
+ private void acceptCommon(InboundContext context, List lanes) {
+ for (String zone : List.of("FLEX", "CACHE", "RESERVE")) {
+ retain("COMMON_SINGLE:" + zone, best(lanes.stream().filter(Lane::usableForInbound)
+ .filter(lane -> lane.depth() == 1 && lane.isEmptyLane())
+ .filter(lane -> zone.equalsIgnoreCase(lane.spaces().get(0).zoneCode()))
+ .filter(lane -> usableSlot(lane, lane.spaces().get(0), request, context))
+ .map(lane -> score(lane, lane.spaces().get(0), request, context,
+ InboundLadderRung.COMMON_SINGLE, InboundBorrowType.NONE, null, true)).toList()),
+ candidateComparator());
+ }
+ retain("COMMON_CONTINUE", best(lanes.stream().filter(Lane::usableForInbound)
+ .filter(lane -> lane.depth() == 2 && !lane.occupied().isEmpty())
+ .filter(lane -> lane.nextSlot().isPresent())
+ .filter(lane -> lane.occupied().stream().allMatch(space -> commonCompatible(space, request, context)))
+ .filter(lane -> usableSlot(lane, lane.nextSlot().orElseThrow(), request, context))
+ .map(lane -> score(lane, lane.nextSlot().orElseThrow(), request, context,
+ InboundLadderRung.COMMON_LANE_CONTINUE, InboundBorrowType.NONE, null, false)).toList()),
+ candidateComparator());
+ retain("COMMON_OPEN", best(emptyLaneCandidates(request, context, lanes, 2,
+ InboundLadderRung.COMMON_LANE_OPEN)), candidateComparator());
+ }
+
+ private Optional selected() {
+ if (policy.isCommon(request.projectNo())) {
+ for (String zone : List.of("FLEX", "CACHE", "RESERVE")) {
+ Candidate candidate = retained.get("COMMON_SINGLE:" + zone);
+ if (candidate != null) return Optional.of(candidate);
+ }
+ if (retained.containsKey("COMMON_CONTINUE")) return Optional.of(retained.get("COMMON_CONTINUE"));
+ return Optional.ofNullable(retained.get("COMMON_OPEN"));
+ }
+ if (retained.containsKey("CONTINUE")) return Optional.of(retained.get("CONTINUE"));
+ if (retained.containsKey("GRADED")) return Optional.of(retained.get("GRADED"));
+ int targetDepth = gradedTargetDepth(request,
+ new InboundContext(List.of(), policy, pendingOutbounds, occupiedCount));
+ if (targetDepth == 2) {
+ Optional pool = selectedPool();
+ if (pool.isPresent()) return pool;
+ }
+ for (int depth : ADJACENT_DEPTH_ORDER.getOrDefault(targetDepth, List.of())) {
+ Candidate candidate = retained.get("ADJACENT:" + depth);
+ if (candidate != null) return Optional.of(candidate);
+ }
+ if (retained.containsKey("MIXED")) return Optional.of(retained.get("MIXED"));
+ return selectedPool();
+ }
+
+ private Optional selectedPool() {
+ if (policy.commonSinglePoolReserve() > 0
+ && freeSinglePool - 1 < policy.commonSinglePoolReserve()) return Optional.empty();
+ for (String zone : List.of("FLEX", "CACHE", "RESERVE")) {
+ Candidate candidate = retained.get("POOL:" + zone);
+ if (candidate != null) return Optional.of(candidate);
+ }
+ return Optional.empty();
+ }
+
+ private void retain(String key, Optional candidate, Comparator comparator) {
+ candidate.ifPresent(value -> retained.merge(key, value,
+ (current, proposed) -> comparator.compare(proposed, current) < 0 ? proposed : current));
+ }
+
+ private List pagedTrace(boolean sealed) {
+ if (policy.isCommon(request.projectNo())) {
+ return List.of(
+ new LadderTraceStep(InboundLadderRung.COMMON_SINGLE,
+ "No outbound-free and reservation-compatible FLEX, CACHE or RESERVE single slot."),
+ new LadderTraceStep(InboundLadderRung.COMMON_LANE_CONTINUE,
+ "No physically continuous outbound-free double lane contains only the same common product."),
+ new LadderTraceStep(InboundLadderRung.COMMON_LANE_OPEN,
+ "No completely empty outbound-free double lane is available."));
+ }
+ int targetDepth = gradedTargetDepth(request,
+ new InboundContext(List.of(), policy, pendingOutbounds, occupiedCount));
+ boolean guard = policy.commonSinglePoolReserve() > 0
+ && freeSinglePool - 1 < policy.commonSinglePoolReserve();
+ return List.of(
+ new LadderTraceStep(InboundLadderRung.CONTINUE,
+ "No physically continuous outbound-free lane can legally continue this project/cohort."),
+ new LadderTraceStep(InboundLadderRung.OPEN_GRADED,
+ "No completely empty legal lane at graded depth " + targetDepth + "."),
+ new LadderTraceStep(InboundLadderRung.OPEN_ADJACENT,
+ "No completely empty legal lane in adjacent order "
+ + ADJACENT_DEPTH_ORDER.getOrDefault(targetDepth, List.of()) + "."),
+ new LadderTraceStep(InboundLadderRung.MIXED_GUEST,
+ sealed ? "SEALED admission never mixes projects or lots."
+ : "No one-segment non-isolated resident lane without pending outbound can accept one outer guest."),
+ new LadderTraceStep(InboundLadderRung.POOL_BORROW,
+ guard ? "Single-pool common-parts watermark would be breached."
+ : "No legal FLEX/CACHE single slot, or high-water RESERVE slot, is available."));
+ }
+ }
+
+ private void validateManualReservations(InboundContext context) {
+ List laneCodes = context.spaces().stream().map(SpaceState::laneCode).distinct().toList();
+ context.policy().manualReservedLanes().stream()
+ .filter(reservation -> !laneCodes.contains(reservation.laneCode()))
+ .findFirst()
+ .ifPresent(reservation -> {
+ throw new IllegalArgumentException(
+ "manualReservedLanes references unknown lane " + reservation.laneCode());
+ });
+ }
+
+ private Optional chooseProject(InboundRequest request, InboundContext context, List lanes) {
+ Optional candidate = best(lanes.stream()
+ .filter(lane -> lane.usableForInbound() && !lane.occupied().isEmpty())
+ .map(lane -> continueCandidate(request, context, lane))
+ .flatMap(Optional::stream)
+ .toList());
+ if (candidate.isPresent()) return candidate;
+
+ int targetDepth = gradedTargetDepth(request, context);
+ candidate = best(emptyLaneCandidates(request, context, lanes, targetDepth, InboundLadderRung.OPEN_GRADED));
+ if (candidate.isPresent()) return candidate;
+
+ if (targetDepth == 2) {
+ candidate = bestPoolSingle(request, context, lanes).candidate();
+ if (candidate.isPresent()) return candidate;
+ }
+
+ for (int depth : ADJACENT_DEPTH_ORDER.getOrDefault(targetDepth, List.of())) {
+ candidate = best(emptyLaneCandidates(request, context, lanes, depth, InboundLadderRung.OPEN_ADJACENT));
+ if (candidate.isPresent()) return candidate;
+ }
+
+ if (request.batchLaneMode() != BatchLaneMode.SEALED
+ && request.projectNo() != null
+ && !context.policy().isIsolated(request.projectNo())
+ && !context.policy().isCommon(request.projectNo())
+ && context.policy().allowsMixed(request.projectNo())) {
+ candidate = bestMixed(request, context, lanes);
+ if (candidate.isPresent()) return candidate;
+ }
+
+ return bestPoolSingle(request, context, lanes).candidate();
+ }
+
+ private Optional chooseCommon(InboundRequest request, InboundContext context, List lanes) {
+ for (String zone : List.of("FLEX", "CACHE", "RESERVE")) {
+ Optional single = best(lanes.stream()
+ .filter(Lane::usableForInbound)
+ .filter(lane -> lane.depth() == 1 && lane.isEmptyLane())
+ .filter(lane -> zone.equalsIgnoreCase(lane.spaces().get(0).zoneCode()))
+ .filter(lane -> usableSlot(lane, lane.spaces().get(0), request, context))
+ .map(lane -> score(lane, lane.spaces().get(0), request, context,
+ InboundLadderRung.COMMON_SINGLE, InboundBorrowType.NONE, null, true))
+ .toList());
+ if (single.isPresent()) return single;
+ }
+
+ Optional continuation = best(lanes.stream()
+ .filter(Lane::usableForInbound)
+ .filter(lane -> lane.depth() == 2 && !lane.occupied().isEmpty())
+ .filter(lane -> lane.nextSlot().isPresent())
+ .filter(lane -> lane.occupied().stream().allMatch(space -> commonCompatible(space, request, context)))
+ .filter(lane -> usableSlot(lane, lane.nextSlot().orElseThrow(), request, context))
+ .map(lane -> score(lane, lane.nextSlot().orElseThrow(), request, context,
+ InboundLadderRung.COMMON_LANE_CONTINUE, InboundBorrowType.NONE, null, false))
+ .toList());
+ if (continuation.isPresent()) return continuation;
+
+ return best(emptyLaneCandidates(request, context, lanes, 2, InboundLadderRung.COMMON_LANE_OPEN));
+ }
+
+ private boolean commonCompatible(SpaceState space, InboundRequest request, InboundContext context) {
+ return context.policy().isCommon(space.projectNo())
+ && Objects.equals(space.productId(), request.productId());
+ }
+
+ private Optional continueCandidate(InboundRequest request, InboundContext context, Lane lane) {
+ if (lane.segments().isEmpty()) return Optional.empty();
+ Segment resident = lane.segments().get(0);
+ boolean residentMatch = lane.segments().size() == 1
+ && Objects.equals(resident.projectNo(), request.projectNo());
+ boolean guestMatch = lane.segments().size() == 2
+ && Objects.equals(lane.segments().get(1).projectNo(), request.projectNo());
+ if (!residentMatch && !guestMatch) return Optional.empty();
+
+ Optional slot = guestMatch ? lane.outerSlot() : lane.nextSlot();
+ if (slot.isEmpty() || !usableSlot(lane, slot.orElseThrow(), request, context)) return Optional.empty();
+ if (guestMatch && (!context.policy().allowsMixed(request.projectNo())
+ || !context.policy().allowsMixed(resident.projectNo()))) {
+ return Optional.empty();
+ }
+ if (request.batchLaneMode() == BatchLaneMode.SEALED && lane.occupied().stream().anyMatch(space ->
+ !Objects.equals(space.projectNo(), request.projectNo())
+ || !Objects.equals(space.productId(), request.productId())
+ || !Objects.equals(space.lotNo(), request.lotNo()))) {
+ return Optional.empty();
+ }
+ return Optional.of(score(lane, slot.orElseThrow(), request, context, InboundLadderRung.CONTINUE,
+ guestMatch ? InboundBorrowType.MIXED_IN : InboundBorrowType.NONE,
+ guestMatch ? resident.projectNo() : null, false));
+ }
+
+ private List emptyLaneCandidates(InboundRequest request, InboundContext context,
+ List lanes, int depth, InboundLadderRung rung) {
+ return lanes.stream()
+ .filter(Lane::usableForInbound)
+ .filter(lane -> lane.depth() == depth && lane.isEmptyLane())
+ .filter(lane -> lane.spaces().stream().allMatch(space -> space.role() == SpaceRole.STORAGE))
+ .filter(lane -> usableSlot(lane, lane.spaces().get(0), request, context))
+ .map(lane -> score(lane, lane.spaces().get(0), request, context, rung,
+ InboundBorrowType.NONE, null, true))
+ .toList();
+ }
+
+ private PoolChoice bestPoolSingle(InboundRequest request, InboundContext context, List lanes) {
+ List pool = lanes.stream()
+ .filter(lane -> lane.depth() == 1)
+ .filter(lane -> lane.spaces().stream().allMatch(space -> space.role() == SpaceRole.STORAGE))
+ .toList();
+ long freeTotal = pool.stream().filter(Lane::isEmptyLane).count();
+ if (context.policy().commonSinglePoolReserve() > 0
+ && freeTotal - 1 < context.policy().commonSinglePoolReserve()) {
+ return new PoolChoice(Optional.empty(), true);
+ }
+
+ List tiers = new ArrayList<>();
+ tiers.add(new PoolTier("FLEX", InboundBorrowType.NONE));
+ tiers.add(new PoolTier("CACHE", InboundBorrowType.CACHE_BORROWED));
+ if (context.occupiedCount() >= context.policy().schedulingPolicy().capacityFirstOccupied()) {
+ tiers.add(new PoolTier("RESERVE", InboundBorrowType.RESERVE_BORROWED));
+ }
+ for (PoolTier tier : tiers) {
+ Optional found = best(pool.stream()
+ .filter(Lane::usableForInbound)
+ .filter(Lane::isEmptyLane)
+ .filter(lane -> tier.zone().equalsIgnoreCase(lane.spaces().get(0).zoneCode()))
+ .filter(lane -> usableSlot(lane, lane.spaces().get(0), request, context))
+ .map(lane -> score(lane, lane.spaces().get(0), request, context,
+ InboundLadderRung.POOL_BORROW, tier.borrowType(), null, true))
+ .toList());
+ if (found.isPresent()) return new PoolChoice(found, false);
+ }
+ return new PoolChoice(Optional.empty(), false);
+ }
+
+ private Optional bestMixed(InboundRequest request, InboundContext context, List lanes) {
+ Comparator mixedOrder = Comparator.comparingInt(Candidate::remaining)
+ .thenComparing(Candidate::residentNewest)
+ .thenComparing(candidateComparator());
+ return lanes.stream()
+ .filter(Lane::usableForInbound)
+ .filter(lane -> lane.depth() >= 2 && lane.segments().size() == 1 && lane.outerSlot().isPresent())
+ .filter(lane -> lane.segments().get(0).projectNo() != null)
+ .filter(lane -> !Objects.equals(lane.segments().get(0).projectNo(), request.projectNo()))
+ .filter(lane -> !context.policy().isIsolated(lane.segments().get(0).projectNo()))
+ .filter(lane -> !context.policy().isCommon(lane.segments().get(0).projectNo()))
+ .filter(lane -> context.policy().allowsMixed(request.projectNo()))
+ .filter(lane -> context.policy().allowsMixed(lane.segments().get(0).projectNo()))
+ .filter(lane -> usableSlot(lane, lane.outerSlot().orElseThrow(), request, context))
+ .map(lane -> score(lane, lane.outerSlot().orElseThrow(), request, context,
+ InboundLadderRung.MIXED_GUEST, InboundBorrowType.MIXED_IN,
+ lane.segments().get(0).projectNo(), false))
+ .min(mixedOrder);
+ }
+
+ private List traceFor(InboundRequest request, InboundContext context, List lanes) {
+ if (context.policy().isCommon(request.projectNo())) {
+ return List.of(
+ new LadderTraceStep(InboundLadderRung.COMMON_SINGLE,
+ "No outbound-free and reservation-compatible FLEX, CACHE or RESERVE single slot."),
+ new LadderTraceStep(InboundLadderRung.COMMON_LANE_CONTINUE,
+ "No physically continuous outbound-free double lane contains only the same common product."),
+ new LadderTraceStep(InboundLadderRung.COMMON_LANE_OPEN,
+ "No completely empty outbound-free double lane is available."));
+ }
+
+ int targetDepth = gradedTargetDepth(request, context);
+ PoolChoice pool = bestPoolSingle(request, context, lanes);
+ String poolReason = pool.guardBlocked()
+ ? "Single-pool common-parts watermark would be breached."
+ : "No legal FLEX/CACHE single slot, or high-water RESERVE slot, is available.";
+ return List.of(
+ new LadderTraceStep(InboundLadderRung.CONTINUE,
+ "No physically continuous outbound-free lane can legally continue this project/cohort."),
+ new LadderTraceStep(InboundLadderRung.OPEN_GRADED,
+ "No completely empty legal lane at graded depth " + targetDepth + "."),
+ new LadderTraceStep(InboundLadderRung.OPEN_ADJACENT,
+ "No completely empty legal lane in adjacent order " + ADJACENT_DEPTH_ORDER.getOrDefault(targetDepth, List.of()) + "."),
+ new LadderTraceStep(InboundLadderRung.MIXED_GUEST,
+ request.batchLaneMode() == BatchLaneMode.SEALED
+ ? "SEALED admission never mixes projects or lots."
+ : "No one-segment non-isolated resident lane without pending outbound can accept one outer guest."),
+ new LadderTraceStep(InboundLadderRung.POOL_BORROW, poolReason));
+ }
+
+ private int gradedTargetDepth(InboundRequest request, InboundContext context) {
+ if (request.expectedProjectPallets() != null) {
+ if (request.expectedProjectPallets() <= 2) return 2;
+ if (request.expectedProjectPallets() == 3) return 3;
+ return 5;
+ }
+ long current = context.spaces().stream()
+ .filter(SpaceState::occupied)
+ .filter(space -> !space.outboundTaskActive())
+ .filter(space -> Objects.equals(space.projectNo(), request.projectNo()))
+ .count();
+ return current <= 1 ? 2 : 5;
+ }
+
+ private boolean pendingHitsPallet(InboundContext context, SpaceState pallet) {
+ return context.pendingOutbounds().stream().anyMatch(pending -> {
+ boolean exactTray = pending.trayNo() != null && !pending.trayNo().isBlank()
+ && Objects.equals(pending.trayNo(), pallet.trayNo());
+ boolean resolvedLane = pending.hasResolvedLane()
+ && Objects.equals(pending.sourceLaneCode(), pallet.laneCode());
+ return exactTray || resolvedLane;
+ });
+ }
+
+ private boolean usableSlot(Lane lane, SpaceState space, InboundRequest request, InboundContext context) {
+ if (!lane.usableForInbound() || !space.availableEmpty() || space.role() != SpaceRole.STORAGE) return false;
+ if (request.targetFloor() != null && space.floorNo() != request.targetFloor()) return false;
+ if (request.targetFloor() == null && !request.allowedFloors().isEmpty()
+ && !request.allowedFloors().contains(space.floorNo())) return false;
+ return !reservationBlocks(lane.code(), request.projectNo(), context.policy());
+ }
+
+ private boolean reservationBlocks(String laneCode, String projectNo, InboundPolicy policy) {
+ Optional reservation = policy.manualReservedLanes().stream()
+ .filter(item -> item.laneCode().equals(laneCode))
+ .findFirst();
+ if (reservation.isEmpty()) return false;
+ if (reservation.orElseThrow().projectNo() != null) {
+ return !Objects.equals(reservation.orElseThrow().projectNo(), projectNo);
+ }
+ return !policy.isIsolated(projectNo);
+ }
+
+ private Candidate score(Lane lane, SpaceState space, InboundRequest request, InboundContext context,
+ InboundLadderRung rung, InboundBorrowType borrowType,
+ String mixedIntoResidentProject, boolean openedNewLane) {
+ long sameProject = lane.occupied().stream()
+ .filter(item -> request.projectNo() != null && Objects.equals(item.projectNo(), request.projectNo()))
+ .count();
+ double fillRate = lane.spaces().isEmpty() ? 0 : (double) lane.occupied().size() / lane.spaces().size();
+ double codingProgress = (space.laneCapacity() - space.groupCoding() + 1.0) / Math.max(1, space.laneCapacity());
+ double zoneBonus = "MAIN".equalsIgnoreCase(space.zoneCode()) ? 8
+ : "FLEX".equalsIgnoreCase(space.zoneCode()) ? 5 : 0;
+ double capacityScore = clamp(20 + fillRate * 52 + (sameProject > 0 ? 20 : 0)
+ + codingProgress * 15 + zoneBonus);
+ int entryFloor = Math.max(1, Math.min(8, request.entryFloor() == null ? 1 : request.entryFloor()));
+ int aisleDistance = Math.min(Math.abs(space.aisleNo() - 6), Math.abs(space.aisleNo() - 10));
+ double efficiencyScore = clamp(105 - Math.abs(space.floorNo() - entryFloor) * 12
+ - aisleDistance * 3 - numericRow(space.rowCode()) * 0.7);
+ SchedulingPolicy scheduling = context.policy().schedulingPolicy();
+ double capacityWeight = scheduling.capacityWeight();
+ double efficiencyWeight = scheduling.efficiencyWeight();
+ if (context.occupiedCount() >= scheduling.capacityFirstOccupied()) {
+ capacityWeight = 0.8;
+ efficiencyWeight = 0.2;
+ }
+ double sum = capacityWeight + efficiencyWeight;
+ if (sum == 0) {
+ capacityWeight = efficiencyWeight = 0.5;
+ sum = 1;
+ }
+ double totalScore = capacityScore * capacityWeight / sum + efficiencyScore * efficiencyWeight / sum;
+ Instant residentNewest = lane.occupied().stream()
+ .map(SpaceState::inboundAt)
+ .filter(Objects::nonNull)
+ .max(Comparator.naturalOrder())
+ .orElse(Instant.MAX);
+ if (lane.occupied().stream().anyMatch(item -> item.inboundAt() == null)) residentNewest = Instant.MAX;
+ return new Candidate(space, lane, rung, borrowType, mixedIntoResidentProject, openedNewLane,
+ round1(capacityScore), round1(efficiencyScore), round1(totalScore), round1(fillRate * 100),
+ lane.spaces().size() - lane.occupied().size(), residentNewest);
+ }
+
+ private Optional best(List candidates) {
+ return candidates.stream().min(candidateComparator());
+ }
+
+ private Comparator candidateComparator() {
+ return Comparator.comparingDouble(Candidate::totalScore).reversed()
+ .thenComparing(Comparator.comparingDouble(Candidate::capacityScore).reversed())
+ .thenComparing(candidate -> candidate.space().spaceId());
+ }
+
+ private InboundAssignment toAssignment(InboundRequest request, Candidate candidate) {
+ SpaceState space = candidate.space();
+ String reason = "Selected the innermost legal slot " + space.spaceId() + " in lane "
+ + candidate.lane().code() + " at rung " + candidate.rung()
+ + "; fill=" + candidate.fillRatePercent() + "%, capacity=" + candidate.capacityScore()
+ + ", efficiency=" + candidate.efficiencyScore() + ", total=" + candidate.totalScore() + ".";
+ return new InboundAssignment(request.requestId(), space.spaceId(), candidate.lane().code(),
+ space.laneCapacity(), space.depthOrder(), candidate.rung(), candidate.borrowType(),
+ candidate.mixedIntoResidentProject(), candidate.capacityScore(), candidate.efficiencyScore(),
+ candidate.totalScore(), candidate.fillRatePercent(), request.batchLaneMode() == BatchLaneMode.SEALED,
+ candidate.openedNewLane(), reason);
+ }
+
+ private List buildLanes(InboundContext context) {
+ Map> grouped = context.spaces().stream().collect(Collectors.groupingBy(
+ SpaceState::laneCode, LinkedHashMap::new, Collectors.toList()));
+ return grouped.entrySet().stream().map(entry -> analyzeLane(entry.getKey(), entry.getValue(), context))
+ .toList();
+ }
+
+ private Lane analyzeLane(String code, List input, InboundContext context) {
+ List spaces = input.stream()
+ .sorted(Comparator.comparingInt(SpaceState::depthOrder).thenComparing(SpaceState::spaceId))
+ .toList();
+ List occupied = spaces.stream().filter(SpaceState::occupied).toList();
+ boolean gapSeen = false;
+ boolean continuous = true;
+ boolean invalid = false;
+ for (SpaceState space : spaces) {
+ if (!space.occupied()) {
+ gapSeen = true;
+ } else if (gapSeen) {
+ continuous = false;
+ }
+ if (space.inventoryProjectConflict()
+ || (space.status() == SpaceStatus.EMPTY && space.hasTray())) invalid = true;
+ }
+
+ List segments = new ArrayList<>();
+ for (SpaceState space : occupied) {
+ String key = segmentKey(space, context.policy());
+ if (segments.isEmpty() || !segments.get(segments.size() - 1).key().equals(key)) {
+ segments.add(new Segment(key, space.projectNo(), space.productId()));
+ }
+ }
+ int depth = spaces.stream().mapToInt(SpaceState::laneCapacity).max().orElse(0);
+ boolean completeTopology = spaces.size() == depth;
+ for (int index = 0; index < spaces.size(); index++) {
+ SpaceState space = spaces.get(index);
+ if (space.depthOrder() != index + 1 || space.laneCapacity() != depth) {
+ completeTopology = false;
+ break;
+ }
+ }
+ boolean corrupt = invalid || !continuous || !completeTopology || segments.size() > 2;
+ int maxOccupiedDepth = occupied.stream().mapToInt(SpaceState::depthOrder).max().orElse(0);
+ Optional immediateOuterSlot = corrupt ? Optional.empty()
+ : spaces.stream().filter(SpaceState::availableEmpty)
+ .filter(space -> space.depthOrder() == maxOccupiedDepth + 1).findFirst();
+ boolean emptyLane = occupied.isEmpty() && spaces.stream().allMatch(SpaceState::availableEmpty);
+ boolean outboundBlocked = spaces.stream().anyMatch(SpaceState::outboundTaskActive)
+ || occupied.stream().anyMatch(space -> pendingHitsPallet(context, space));
+ return new Lane(code, depth, spaces, occupied, segments, corrupt, outboundBlocked,
+ emptyLane, immediateOuterSlot, immediateOuterSlot);
+ }
+
+ private String segmentKey(SpaceState space, InboundPolicy policy) {
+ if (space.projectNo() == null) return "P|__UNKNOWN__";
+ if (policy.isCommon(space.projectNo())) {
+ return "C|" + space.projectNo() + "|" + Objects.toString(space.productId(), "");
+ }
+ return "P|" + space.projectNo();
+ }
+
+ private double numericRow(String rowCode) {
+ try {
+ return Double.parseDouble(rowCode);
+ } catch (RuntimeException ignored) {
+ return 0;
+ }
+ }
+
+ private double clamp(double value) {
+ return Math.max(0, Math.min(100, value));
+ }
+
+ private double round1(double value) {
+ return Math.round(value * 10.0) / 10.0;
+ }
+
+ private record Segment(String key, String projectNo, String productId) {
+ }
+
+ private record Lane(
+ String code,
+ int depth,
+ List spaces,
+ List occupied,
+ List segments,
+ boolean corrupt,
+ boolean outboundBlocked,
+ boolean isEmptyLane,
+ Optional nextSlot,
+ Optional outerSlot
+ ) {
+ private boolean usableForInbound() {
+ return !corrupt && !outboundBlocked;
+ }
+ }
+
+ private record Candidate(
+ SpaceState space,
+ Lane lane,
+ InboundLadderRung rung,
+ InboundBorrowType borrowType,
+ String mixedIntoResidentProject,
+ boolean openedNewLane,
+ double capacityScore,
+ double efficiencyScore,
+ double totalScore,
+ double fillRatePercent,
+ int remaining,
+ Instant residentNewest
+ ) {
+ }
+
+ private record PoolTier(String zone, InboundBorrowType borrowType) {
+ }
+
+ private record PoolChoice(Optional candidate, boolean guardBlocked) {
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundPolicy.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundPolicy.java
new file mode 100644
index 0000000..58354f1
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundPolicy.java
@@ -0,0 +1,51 @@
+package com.ruoyi.algorithm.inbound;
+
+import com.ruoyi.algorithm.policy.SchedulingPolicy;
+
+import java.util.List;
+import java.util.Set;
+
+public record InboundPolicy(
+ SchedulingPolicy schedulingPolicy,
+ Set isolatedProjects,
+ Set commonProjectNos,
+ List manualReservedLanes,
+ int commonSinglePoolReserve,
+ Set mixedDeniedProjects
+) {
+ public InboundPolicy {
+ schedulingPolicy = schedulingPolicy == null ? SchedulingPolicy.ft546Defaults() : schedulingPolicy;
+ isolatedProjects = isolatedProjects == null ? Set.of() : Set.copyOf(isolatedProjects);
+ commonProjectNos = commonProjectNos == null ? Set.of("COMMON") : Set.copyOf(commonProjectNos);
+ manualReservedLanes = manualReservedLanes == null ? List.of() : List.copyOf(manualReservedLanes);
+ mixedDeniedProjects = mixedDeniedProjects == null ? Set.of() : Set.copyOf(mixedDeniedProjects);
+ if (commonSinglePoolReserve < 0) throw new IllegalArgumentException("commonSinglePoolReserve cannot be negative");
+ long distinctLanes = manualReservedLanes.stream().map(ManualLaneReservation::laneCode).distinct().count();
+ if (distinctLanes != manualReservedLanes.size()) {
+ throw new IllegalArgumentException("manualReservedLanes contains a duplicate lane");
+ }
+ }
+
+ public InboundPolicy(SchedulingPolicy schedulingPolicy, Set isolatedProjects,
+ Set commonProjectNos, List manualReservedLanes,
+ int commonSinglePoolReserve) {
+ this(schedulingPolicy, isolatedProjects, commonProjectNos, manualReservedLanes,
+ commonSinglePoolReserve, Set.of());
+ }
+
+ public static InboundPolicy defaults() {
+ return new InboundPolicy(SchedulingPolicy.ft546Defaults(), Set.of(), Set.of("COMMON"), List.of(), 0);
+ }
+
+ public boolean isIsolated(String projectNo) {
+ return projectNo != null && isolatedProjects.contains(projectNo);
+ }
+
+ public boolean isCommon(String projectNo) {
+ return projectNo != null && commonProjectNos.contains(projectNo);
+ }
+
+ public boolean allowsMixed(String projectNo) {
+ return projectNo != null && !mixedDeniedProjects.contains(projectNo);
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundRejection.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundRejection.java
new file mode 100644
index 0000000..bba17fd
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundRejection.java
@@ -0,0 +1,16 @@
+package com.ruoyi.algorithm.inbound;
+
+import java.util.List;
+
+public record InboundRejection(
+ InboundRejectionCode code,
+ String reason,
+ String projectNo,
+ String productId,
+ String lotNo,
+ List ladderTrace
+) {
+ public InboundRejection {
+ ladderTrace = List.copyOf(ladderTrace);
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundRejectionCode.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundRejectionCode.java
new file mode 100644
index 0000000..14ff868
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundRejectionCode.java
@@ -0,0 +1,6 @@
+package com.ruoyi.algorithm.inbound;
+
+public enum InboundRejectionCode {
+ NO_FEASIBLE_LOCATION,
+ NO_BATCH_COMPATIBLE_LANE
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundRequest.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundRequest.java
new file mode 100644
index 0000000..95ac54e
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/InboundRequest.java
@@ -0,0 +1,26 @@
+package com.ruoyi.algorithm.inbound;
+
+import java.util.Set;
+
+public record InboundRequest(
+ String requestId,
+ String trayNo,
+ String projectNo,
+ String productId,
+ String lotNo,
+ Integer expectedProjectPallets,
+ Integer entryFloor,
+ Integer targetFloor,
+ Set allowedFloors,
+ BatchLaneMode batchLaneMode
+) {
+ public InboundRequest {
+ if (requestId == null || requestId.isBlank()) throw new IllegalArgumentException("requestId is required");
+ if (trayNo == null || trayNo.isBlank()) throw new IllegalArgumentException("trayNo is required");
+ if (expectedProjectPallets != null && expectedProjectPallets <= 0) {
+ throw new IllegalArgumentException("expectedProjectPallets must be positive");
+ }
+ allowedFloors = allowedFloors == null ? Set.of() : Set.copyOf(allowedFloors);
+ batchLaneMode = batchLaneMode == null ? BatchLaneMode.OPEN : batchLaneMode;
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/LadderTraceStep.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/LadderTraceStep.java
new file mode 100644
index 0000000..809d519
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/LadderTraceStep.java
@@ -0,0 +1,4 @@
+package com.ruoyi.algorithm.inbound;
+
+public record LadderTraceStep(InboundLadderRung rung, String reason) {
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/ManualLaneReservation.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/ManualLaneReservation.java
new file mode 100644
index 0000000..af41f5f
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/ManualLaneReservation.java
@@ -0,0 +1,9 @@
+package com.ruoyi.algorithm.inbound;
+
+public record ManualLaneReservation(String laneCode, String projectNo) {
+ public ManualLaneReservation {
+ if (laneCode == null || laneCode.isBlank()) {
+ throw new IllegalArgumentException("laneCode is required");
+ }
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/PendingOutbound.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/PendingOutbound.java
new file mode 100644
index 0000000..fe9971d
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/inbound/PendingOutbound.java
@@ -0,0 +1,17 @@
+package com.ruoyi.algorithm.inbound;
+
+public record PendingOutbound(String projectNo, String productId, String trayNo,
+ String sourceLaneCode, String sourceResolution) {
+ public PendingOutbound(String projectNo, String productId, String trayNo) {
+ this(projectNo, productId, trayNo, null, "UNRESOLVED");
+ }
+
+ public boolean hasResolvedLane() {
+ if (sourceLaneCode == null || sourceLaneCode.isBlank()
+ || sourceResolution == null || sourceResolution.isBlank()) {
+ return false;
+ }
+ return !"UNRESOLVED".equalsIgnoreCase(sourceResolution)
+ && !"AMBIGUOUS_TRAY".equalsIgnoreCase(sourceResolution);
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/AlgorithmConfiguration.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/AlgorithmConfiguration.java
new file mode 100644
index 0000000..894a7c1
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/AlgorithmConfiguration.java
@@ -0,0 +1,45 @@
+package com.ruoyi.algorithm.integration;
+
+import com.ruoyi.algorithm.inbound.InboundPlanner;
+import com.ruoyi.algorithm.outbound.OutboundPlanner;
+import com.ruoyi.algorithm.repository.JdbcWarehouseRepository;
+import com.ruoyi.algorithm.repository.RepositoryDialect;
+import com.ruoyi.algorithm.repository.WarehouseRepository;
+import com.ruoyi.algorithm.service.WarehouseApplicationService;
+import com.ruoyi.algorithm.tidy.TidyPlanner;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import javax.sql.DataSource;
+
+@Configuration
+@EnableConfigurationProperties(AlgorithmProperties.class)
+public class AlgorithmConfiguration {
+
+ @Bean
+ WarehouseRepository algorithmWarehouseRepository(DataSource dataSource, AlgorithmProperties properties,
+ PlatformTransactionManager transactionManager) {
+ if (transactionManager instanceof DataSourceTransactionManager dataSourceTransactionManager
+ && dataSourceTransactionManager.getDataSource() != dataSource) {
+ throw new IllegalStateException("Algorithm repository and WMS must use the same DataSource");
+ }
+ return new JdbcWarehouseRepository(dataSource, RepositoryDialect.sqlServer(),
+ properties.isDatabaseWritesEnabled(), transactionManager);
+ }
+
+ @Bean
+ WarehouseApplicationService algorithmApplicationService(WarehouseRepository algorithmWarehouseRepository,
+ AlgorithmProperties properties) {
+ return new WarehouseApplicationService(algorithmWarehouseRepository, new InboundPlanner(),
+ new OutboundPlanner(), new TidyPlanner(), properties.getPageSize());
+ }
+
+ @Bean
+ SchedulingFacade schedulingFacade(WarehouseApplicationService algorithmApplicationService,
+ AlgorithmProperties properties) {
+ return new SchedulingFacade(algorithmApplicationService, properties);
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/AlgorithmProperties.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/AlgorithmProperties.java
new file mode 100644
index 0000000..30acd0e
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/AlgorithmProperties.java
@@ -0,0 +1,74 @@
+package com.ruoyi.algorithm.integration;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "fortucky.algorithm")
+public class AlgorithmProperties {
+ private SchedulingMode mode = SchedulingMode.SHADOW;
+ private String warehouseId = "01";
+ private int pageSize = 100;
+ private boolean databaseWritesEnabled = true;
+ private boolean legacyFallbackOnTechnicalError;
+ private long tidyAutoScanMs = 600_000L;
+ private long tidyReconcileMs = 5_000L;
+ private int tidyClaimLeaseSeconds = 60;
+ private int tidyManualMaxConcurrency = 3;
+ private String tidyWcsSnapshotUrl = "http://localhost:8081/wcs2Wms/api/tidyVehicleSnapshot";
+ private String replayRunId = "";
+ private String replayDatabase = "";
+
+ public SchedulingMode getMode() {
+ return mode;
+ }
+
+ public void setMode(SchedulingMode mode) {
+ this.mode = mode;
+ }
+
+ public String getWarehouseId() {
+ return warehouseId;
+ }
+
+ public void setWarehouseId(String warehouseId) {
+ this.warehouseId = warehouseId;
+ }
+
+ public int getPageSize() {
+ return pageSize;
+ }
+
+ public void setPageSize(int pageSize) {
+ this.pageSize = pageSize;
+ }
+
+ public boolean isDatabaseWritesEnabled() {
+ return databaseWritesEnabled;
+ }
+
+ public void setDatabaseWritesEnabled(boolean databaseWritesEnabled) {
+ this.databaseWritesEnabled = databaseWritesEnabled;
+ }
+
+ public boolean isLegacyFallbackOnTechnicalError() {
+ return legacyFallbackOnTechnicalError;
+ }
+
+ public void setLegacyFallbackOnTechnicalError(boolean legacyFallbackOnTechnicalError) {
+ this.legacyFallbackOnTechnicalError = legacyFallbackOnTechnicalError;
+ }
+
+ public long getTidyAutoScanMs() { return tidyAutoScanMs; }
+ public void setTidyAutoScanMs(long value) { this.tidyAutoScanMs = value; }
+ public long getTidyReconcileMs() { return tidyReconcileMs; }
+ public void setTidyReconcileMs(long value) { this.tidyReconcileMs = value; }
+ public int getTidyClaimLeaseSeconds() { return tidyClaimLeaseSeconds; }
+ public void setTidyClaimLeaseSeconds(int value) { this.tidyClaimLeaseSeconds = value; }
+ public int getTidyManualMaxConcurrency() { return tidyManualMaxConcurrency; }
+ public void setTidyManualMaxConcurrency(int value) { this.tidyManualMaxConcurrency = value; }
+ public String getTidyWcsSnapshotUrl() { return tidyWcsSnapshotUrl; }
+ public void setTidyWcsSnapshotUrl(String value) { this.tidyWcsSnapshotUrl = value; }
+ public String getReplayRunId() { return replayRunId; }
+ public void setReplayRunId(String value) { this.replayRunId = value; }
+ public String getReplayDatabase() { return replayDatabase; }
+ public void setReplayDatabase(String value) { this.replayDatabase = value; }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingDecisionStatus.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingDecisionStatus.java
new file mode 100644
index 0000000..621c069
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingDecisionStatus.java
@@ -0,0 +1,8 @@
+package com.ruoyi.algorithm.integration;
+
+public enum SchedulingDecisionStatus {
+ READY,
+ WAITING,
+ REJECTED,
+ TECHNICAL_ERROR
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingFacade.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingFacade.java
new file mode 100644
index 0000000..a684c9f
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingFacade.java
@@ -0,0 +1,82 @@
+package com.ruoyi.algorithm.integration;
+
+import com.ruoyi.algorithm.inbound.InboundRequest;
+import com.ruoyi.algorithm.outbound.OutboundPlan;
+import com.ruoyi.algorithm.outbound.OutboundRequest;
+import com.ruoyi.algorithm.repository.DeepWorkflowMutationResult;
+import com.ruoyi.algorithm.repository.ReservationResult;
+import com.ruoyi.algorithm.service.InboundPlanningResult;
+import com.ruoyi.algorithm.service.InboundReservationOutcome;
+import com.ruoyi.algorithm.service.WarehouseApplicationService;
+import com.ruoyi.algorithm.tidy.TidyPlan;
+
+import java.time.Instant;
+
+import java.util.Objects;
+
+public final class SchedulingFacade {
+ private final WarehouseApplicationService applicationService;
+ private final AlgorithmProperties properties;
+ private final SchedulingRoutingPolicy routingPolicy;
+
+ public SchedulingFacade(WarehouseApplicationService applicationService, AlgorithmProperties properties) {
+ this.applicationService = Objects.requireNonNull(applicationService, "applicationService");
+ this.properties = Objects.requireNonNull(properties, "properties");
+ this.routingPolicy = new SchedulingRoutingPolicy(properties.getMode(),
+ properties.isLegacyFallbackOnTechnicalError());
+ }
+
+ public SchedulingRoutingPolicy routingPolicy() {
+ return routingPolicy;
+ }
+
+ public String defaultWarehouseId() {
+ return properties.getWarehouseId();
+ }
+
+ public InboundPlanningResult planInbound(String warehouseId, InboundRequest request) {
+ requireAlgorithmMode();
+ return applicationService.planInbound(warehouseId, request);
+ }
+
+ public InboundReservationOutcome reserveInbound(String reservationId, String warehouseId,
+ String projectNo, InboundPlanningResult plan) {
+ requireActiveMode();
+ return applicationService.reserveInbound(reservationId, warehouseId, projectNo, plan);
+ }
+
+ public OutboundPlan planOutbound(String warehouseId, OutboundRequest request) {
+ requireAlgorithmMode();
+ return applicationService.planOutbound(warehouseId, request);
+ }
+
+ public TidyPlan planTidy(String warehouseId, Instant now) {
+ requireAlgorithmMode();
+ return applicationService.planTidy(warehouseId, now);
+ }
+
+ public ReservationResult releaseInboundReservation(String warehouseId, String reservationId) {
+ return applicationService.releaseReservation(warehouseId, reservationId);
+ }
+
+ public DeepWorkflowMutationResult acquireDeepWorkflowLock(String warehouseId, String requestId, int floorNo) {
+ requireActiveMode();
+ return applicationService.acquireDeepWorkflowLock(warehouseId, requestId, floorNo);
+ }
+
+ public DeepWorkflowMutationResult releaseDeepWorkflowLock(String warehouseId, String requestId, int floorNo) {
+ return applicationService.releaseDeepWorkflowLock(warehouseId, requestId, floorNo);
+ }
+
+ private void requireAlgorithmMode() {
+ if (!routingPolicy.runAlgorithm()) {
+ throw new IllegalStateException("Java scheduling is disabled in LEGACY mode");
+ }
+ }
+
+ private void requireActiveMode() {
+ if (!routingPolicy.algorithmOwnsDecision()) {
+ throw new IllegalStateException("Algorithm writes require ACTIVE mode");
+ }
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingMode.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingMode.java
new file mode 100644
index 0000000..e5a7395
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingMode.java
@@ -0,0 +1,7 @@
+package com.ruoyi.algorithm.integration;
+
+public enum SchedulingMode {
+ LEGACY,
+ SHADOW,
+ ACTIVE
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingRoutingPolicy.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingRoutingPolicy.java
new file mode 100644
index 0000000..89e4858
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingRoutingPolicy.java
@@ -0,0 +1,27 @@
+package com.ruoyi.algorithm.integration;
+
+import java.util.Objects;
+
+public record SchedulingRoutingPolicy(SchedulingMode mode, boolean legacyFallbackOnTechnicalError) {
+
+ public SchedulingRoutingPolicy {
+ mode = Objects.requireNonNull(mode, "mode");
+ }
+
+ public boolean runAlgorithm() {
+ return mode != SchedulingMode.LEGACY;
+ }
+
+ public boolean algorithmOwnsDecision() {
+ return mode == SchedulingMode.ACTIVE;
+ }
+
+ public boolean runLegacy(SchedulingDecisionStatus status) {
+ Objects.requireNonNull(status, "status");
+ return switch (mode) {
+ case LEGACY, SHADOW -> true;
+ case ACTIVE -> status == SchedulingDecisionStatus.TECHNICAL_ERROR
+ && legacyFallbackOnTechnicalError;
+ };
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/model/LaneState.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/model/LaneState.java
new file mode 100644
index 0000000..09b878b
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/model/LaneState.java
@@ -0,0 +1,19 @@
+package com.ruoyi.algorithm.model;
+
+import java.util.List;
+import java.util.Optional;
+
+public record LaneState(
+ List spaces,
+ boolean physicallyContinuous,
+ boolean corrupt,
+ int segmentCount,
+ Optional residentProjectNo,
+ Optional guestProjectNo,
+ Optional nextSlot,
+ Optional outerSlot
+) {
+ public LaneState {
+ spaces = List.copyOf(spaces);
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/model/SpaceRole.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/model/SpaceRole.java
new file mode 100644
index 0000000..d64e94e
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/model/SpaceRole.java
@@ -0,0 +1,8 @@
+package com.ruoyi.algorithm.model;
+
+public enum SpaceRole {
+ STORAGE,
+ RELOCATION_BUFFER,
+ OUTBOUND_CACHE,
+ PROTECTED_CORE
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/model/SpaceState.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/model/SpaceState.java
new file mode 100644
index 0000000..07b8505
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/model/SpaceState.java
@@ -0,0 +1,54 @@
+package com.ruoyi.algorithm.model;
+
+import java.time.Instant;
+
+public record SpaceState(
+ String spaceId,
+ String zoneCode,
+ String laneCode,
+ int depthOrder,
+ int laneCapacity,
+ int floorNo,
+ int aisleNo,
+ int groupCoding,
+ String rowCode,
+ SpaceRole role,
+ SpaceStatus status,
+ boolean outboundTaskActive,
+ boolean inventoryProjectConflict,
+ String trayNo,
+ String projectNo,
+ String productId,
+ String lotNo,
+ Instant inboundAt,
+ Instant outboundAt,
+ boolean reserved,
+ String rowVersion,
+ boolean physicalStockPresent
+) {
+ public SpaceState(String spaceId, String zoneCode, String laneCode, int depthOrder, int laneCapacity,
+ int floorNo, int aisleNo, int groupCoding, String rowCode, SpaceRole role,
+ SpaceStatus status, boolean outboundTaskActive, boolean inventoryProjectConflict,
+ String trayNo, String projectNo, String productId, String lotNo, Instant inboundAt,
+ Instant outboundAt, boolean reserved, String rowVersion) {
+ this(spaceId, zoneCode, laneCode, depthOrder, laneCapacity, floorNo, aisleNo, groupCoding, rowCode,
+ role, status, outboundTaskActive, inventoryProjectConflict, trayNo, projectNo, productId,
+ lotNo, inboundAt, outboundAt, reserved, rowVersion, false);
+ }
+
+ public boolean hasTray() {
+ return trayNo != null && !trayNo.isBlank();
+ }
+
+ public boolean occupied() {
+ return status == SpaceStatus.OCCUPIED || hasTray() || physicalStockPresent;
+ }
+
+ public boolean availableEmpty() {
+ return status == SpaceStatus.EMPTY && !reserved && !hasTray() && !physicalStockPresent;
+ }
+
+ public boolean outboundSelectable() {
+ return status == SpaceStatus.OCCUPIED && !reserved && hasTray();
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/model/SpaceStatus.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/model/SpaceStatus.java
new file mode 100644
index 0000000..8878d05
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/model/SpaceStatus.java
@@ -0,0 +1,7 @@
+package com.ruoyi.algorithm.model;
+
+public enum SpaceStatus {
+ EMPTY,
+ OCCUPIED,
+ DISABLED
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/FifoDecision.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/FifoDecision.java
new file mode 100644
index 0000000..e0c6a97
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/FifoDecision.java
@@ -0,0 +1,25 @@
+package com.ruoyi.algorithm.outbound;
+
+import java.time.Instant;
+import java.util.Optional;
+
+public record FifoDecision(
+ boolean fifoApplied,
+ String fifoMode,
+ Integer fifoRank,
+ Instant oldestInboundTime,
+ Instant selectedInboundTime,
+ int fifoDeviationMinutes,
+ boolean fifoViolation,
+ String reason,
+ Optional warning
+) {
+ public FifoDecision {
+ warning = warning == null ? Optional.empty() : warning;
+ }
+
+ public static FifoDecision off() {
+ return new FifoDecision(false, "OFF", null, null, null, 0, false,
+ "FIFO is disabled", Optional.empty());
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/FifoWarning.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/FifoWarning.java
new file mode 100644
index 0000000..544fdbe
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/FifoWarning.java
@@ -0,0 +1,4 @@
+package com.ruoyi.algorithm.outbound;
+
+public record FifoWarning(String code, String message) {
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundAssignment.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundAssignment.java
new file mode 100644
index 0000000..828f66c
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundAssignment.java
@@ -0,0 +1,29 @@
+package com.ruoyi.algorithm.outbound;
+
+import java.util.List;
+import java.util.Optional;
+
+public record OutboundAssignment(
+ String requestId,
+ String trayNo,
+ String projectNo,
+ String productId,
+ String sourceSpaceId,
+ int sourceFloor,
+ boolean wholeTray,
+ List blockerSpaceIds,
+ List blockingTrayNos,
+ boolean laneReleasing,
+ double capacityScore,
+ double efficiencyScore,
+ double totalScore,
+ FifoDecision fifo,
+ Optional workflow,
+ String reason
+) {
+ public OutboundAssignment {
+ blockerSpaceIds = List.copyOf(blockerSpaceIds);
+ blockingTrayNos = List.copyOf(blockingTrayNos);
+ workflow = workflow == null ? Optional.empty() : workflow;
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundCapacityMode.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundCapacityMode.java
new file mode 100644
index 0000000..5e6ab38
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundCapacityMode.java
@@ -0,0 +1,7 @@
+package com.ruoyi.algorithm.outbound;
+
+public enum OutboundCapacityMode {
+ BALANCED,
+ WARNING,
+ CAPACITY_FIRST
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundContext.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundContext.java
new file mode 100644
index 0000000..f97abce
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundContext.java
@@ -0,0 +1,33 @@
+package com.ruoyi.algorithm.outbound;
+
+import com.ruoyi.algorithm.model.SpaceState;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public record OutboundContext(
+ List spaces,
+ Map rawInboundTimes,
+ OutboundCapacityMode capacityMode,
+ Set activeDeepWorkflowFloors
+) {
+ public OutboundContext {
+ spaces = List.copyOf(spaces);
+ rawInboundTimes = Map.copyOf(rawInboundTimes);
+ capacityMode = capacityMode == null ? OutboundCapacityMode.BALANCED : capacityMode;
+ activeDeepWorkflowFloors = activeDeepWorkflowFloors == null ? Set.of() : Set.copyOf(activeDeepWorkflowFloors);
+ }
+
+ public OutboundContext(List spaces, Map rawInboundTimes,
+ OutboundCapacityMode capacityMode) {
+ this(spaces, rawInboundTimes, capacityMode, Set.of());
+ }
+
+ public String rawInboundTime(SpaceState space) {
+ if (rawInboundTimes.containsKey(space.spaceId())) {
+ return rawInboundTimes.get(space.spaceId());
+ }
+ return space.inboundAt() == null ? null : space.inboundAt().toString();
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundError.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundError.java
new file mode 100644
index 0000000..5623e17
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundError.java
@@ -0,0 +1,9 @@
+package com.ruoyi.algorithm.outbound;
+
+import java.util.Map;
+
+public record OutboundError(OutboundErrorCode code, String message, Map details) {
+ public OutboundError {
+ details = Map.copyOf(details);
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundErrorCode.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundErrorCode.java
new file mode 100644
index 0000000..21b4ad3
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundErrorCode.java
@@ -0,0 +1,12 @@
+package com.ruoyi.algorithm.outbound;
+
+public enum OutboundErrorCode {
+ STOCK_NOT_FOUND,
+ AMBIGUOUS_TRAY,
+ OUTBOUND_SCOPE_REQUIRED,
+ FIFO_INVALID_MODE,
+ FIFO_INVALID_TOLERANCE,
+ FIFO_SCOPE_REQUIRED,
+ FIFO_DATA_MISSING,
+ FIFO_INVALID_TIME
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundPlan.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundPlan.java
new file mode 100644
index 0000000..a84bc9d
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundPlan.java
@@ -0,0 +1,32 @@
+package com.ruoyi.algorithm.outbound;
+
+import java.util.Optional;
+
+public record OutboundPlan(
+ OutboundPlanStatus status,
+ Optional assignment,
+ Optional waiting,
+ Optional error,
+ TaskCountEstimate taskCountEstimate
+) {
+ public OutboundPlan {
+ assignment = assignment == null ? Optional.empty() : assignment;
+ waiting = waiting == null ? Optional.empty() : waiting;
+ error = error == null ? Optional.empty() : error;
+ }
+
+ public static OutboundPlan ready(OutboundAssignment assignment, TaskCountEstimate estimate) {
+ return new OutboundPlan(OutboundPlanStatus.READY, Optional.of(assignment), Optional.empty(),
+ Optional.empty(), estimate);
+ }
+
+ public static OutboundPlan waiting(OutboundWaiting waiting) {
+ return new OutboundPlan(OutboundPlanStatus.WAITING, Optional.empty(), Optional.of(waiting),
+ Optional.empty(), TaskCountEstimate.zero());
+ }
+
+ public static OutboundPlan error(OutboundError error) {
+ return new OutboundPlan(OutboundPlanStatus.ERROR, Optional.empty(), Optional.empty(),
+ Optional.of(error), TaskCountEstimate.zero());
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundPlanStatus.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundPlanStatus.java
new file mode 100644
index 0000000..91e5a30
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundPlanStatus.java
@@ -0,0 +1,7 @@
+package com.ruoyi.algorithm.outbound;
+
+public enum OutboundPlanStatus {
+ READY,
+ WAITING,
+ ERROR
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundPlanner.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundPlanner.java
new file mode 100644
index 0000000..421f917
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundPlanner.java
@@ -0,0 +1,324 @@
+package com.ruoyi.algorithm.outbound;
+
+import com.ruoyi.algorithm.model.SpaceRole;
+import com.ruoyi.algorithm.model.SpaceState;
+
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+public final class OutboundPlanner {
+
+ public OutboundPlan plan(OutboundRequest request, OutboundContext context) {
+ String mode = normalizedMode(request.fifoMode());
+ if (!List.of("OFF", "STRICT", "FLEXIBLE").contains(mode)) {
+ return error(OutboundErrorCode.FIFO_INVALID_MODE, "Unsupported FIFO mode",
+ Map.of("fifoMode", String.valueOf(request.fifoMode())));
+ }
+ int tolerance = request.fifoToleranceMinutes() == null ? 0 : request.fifoToleranceMinutes();
+ if (tolerance < 0) {
+ return error(OutboundErrorCode.FIFO_INVALID_TOLERANCE,
+ "FIFO tolerance must be a non-negative integer", Map.of("fifoToleranceMinutes", tolerance));
+ }
+ if (blank(request.trayNo()) && blank(request.projectNo())) {
+ return error(OutboundErrorCode.OUTBOUND_SCOPE_REQUIRED,
+ "Outbound without a specified tray requires projectNo", Map.of("requestId", request.requestId()));
+ }
+
+ List physicalMatches = context.spaces().stream()
+ .filter(SpaceState::occupied)
+ .filter(space -> matchesRequest(space, request))
+ .toList();
+ if (physicalMatches.isEmpty()) {
+ return error(OutboundErrorCode.STOCK_NOT_FOUND, "No matching stock was found",
+ Map.of("requestId", request.requestId()));
+ }
+ if (!blank(request.trayNo()) && physicalMatches.stream().noneMatch(SpaceState::outboundSelectable)) {
+ return unavailableSource(physicalMatches.get(0));
+ }
+ List businessMatches = physicalMatches.stream()
+ .filter(SpaceState::outboundSelectable)
+ .toList();
+ if (businessMatches.isEmpty()) {
+ return unavailableSource(physicalMatches.get(0));
+ }
+
+ Selection selection = mode.equals("OFF")
+ ? selectOff(businessMatches, request, context)
+ : selectFifo(businessMatches, request, context, mode, tolerance);
+ if (selection.error() != null) return OutboundPlan.error(selection.error());
+
+ SpaceState source = selection.source();
+ List blockers = actualBlockers(source, context.spaces());
+ List unavailableBlockers = blockers.stream()
+ .filter(blocker -> !blocker.outboundSelectable())
+ .toList();
+ if (!unavailableBlockers.isEmpty()) {
+ return OutboundPlan.waiting(new OutboundWaiting(OutboundWaitingCode.WAITING_CHANNEL,
+ blockers.size(), 0,
+ "A physical outer pallet is allocated, inspected, locked, or reserved; wait and recalculate",
+ true, Map.of("sourceSpaceId", source.spaceId(), "laneCode", source.laneCode(),
+ "blockingSpaceIds", blockers.stream().map(SpaceState::spaceId).toList(),
+ "blockingTrayNos", blockers.stream().map(SpaceState::trayNo).toList(),
+ "unavailableBlockingSpaceIds", unavailableBlockers.stream().map(SpaceState::spaceId).toList())));
+ }
+ List buffers = availableBuffers(source.floorNo(), context.spaces());
+ if (!blockers.isEmpty() && context.activeDeepWorkflowFloors().contains(source.floorNo())) {
+ return OutboundPlan.waiting(new OutboundWaiting(
+ OutboundWaitingCode.WAITING_RELOCATION_BUFFER,
+ blockers.size(), buffers.size(),
+ "An active same-floor deep workflow owns the relocation buffer; wait and recalculate",
+ true, Map.of("floor", source.floorNo(), "sourceSpaceId", source.spaceId(),
+ "laneCode", source.laneCode())));
+ }
+ if (buffers.size() < blockers.size()) {
+ List blockerIds = blockers.stream().map(SpaceState::spaceId).toList();
+ List blockerTrays = blockers.stream().map(SpaceState::trayNo).toList();
+ Map details = new HashMap<>();
+ details.put("floor", source.floorNo());
+ details.put("required", blockers.size());
+ details.put("available", buffers.size());
+ details.put("sourceSpaceId", source.spaceId());
+ details.put("laneCode", source.laneCode());
+ details.put("blockingSpaceIds", blockerIds);
+ details.put("blockingTrayNos", blockerTrays);
+ return OutboundPlan.waiting(new OutboundWaiting(
+ OutboundWaitingCode.WAITING_RELOCATION_BUFFER,
+ blockers.size(), buffers.size(),
+ "Insufficient same-floor relocation buffer; wait and recalculate", true, details));
+ }
+
+ boolean laneReleasing = context.capacityMode() != OutboundCapacityMode.BALANCED
+ && occupiedInLane(source, context.spaces()) == 1;
+ Optional workflow = blockers.isEmpty()
+ ? Optional.empty()
+ : Optional.of(buildWorkflow(request, source, blockers, buffers.subList(0, blockers.size()), context.spaces()));
+ OutboundAssignment assignment = new OutboundAssignment(
+ request.requestId(), source.trayNo(), source.projectNo(), source.productId(), source.spaceId(),
+ source.floorNo(), request.wholeTrayOrDefault(), blockers.stream().map(SpaceState::spaceId).toList(),
+ blockers.stream().map(SpaceState::trayNo).toList(), laneReleasing, 100.0,
+ efficiencyScore(source, request, context), 100.0, selection.fifo(), workflow,
+ "Outbound " + source.spaceId() + " with " + blockers.size() + " blocking tray(s)");
+ TaskCountEstimate estimate = new TaskCountEstimate(0, 1, blockers.size() * 2,
+ source.floorNo() == request.exitFloorOrDefault() ? 0 : 1);
+ return OutboundPlan.ready(assignment, estimate);
+ }
+
+ private Selection selectOff(List matches, OutboundRequest request, OutboundContext context) {
+ Comparator comparator = Comparator
+ .comparingInt((SpaceState space) -> actualBlockers(space, context.spaces()).size())
+ .thenComparingInt(space -> laneReleaseRank(space, context))
+ .thenComparingInt(SpaceState::floorNo)
+ .thenComparing(SpaceState::spaceId);
+ return new Selection(matches.stream().min(comparator).orElseThrow(), FifoDecision.off(), null);
+ }
+
+ private Selection selectFifo(List requestMatches, OutboundRequest request,
+ OutboundContext context, String mode, int tolerance) {
+ SpaceState specified = blank(request.trayNo()) ? null : requestMatches.get(0);
+ String project = specified == null ? request.projectNo() : specified.projectNo();
+ String product = specified == null ? request.productId() : specified.productId();
+ if (blank(project)) {
+ return failed(OutboundErrorCode.FIFO_SCOPE_REQUIRED,
+ "FIFO scope requires a project or a tray that identifies one", Map.of("requestId", request.requestId()));
+ }
+ List scope = context.spaces().stream()
+ .filter(SpaceState::occupied)
+ .filter(space -> project.equals(space.projectNo()))
+ .filter(space -> blank(product) || product.equals(space.productId()))
+ .toList();
+ List missing = scope.stream()
+ .filter(space -> blank(context.rawInboundTime(space)))
+ .map(SpaceState::spaceId)
+ .sorted()
+ .toList();
+ if (!missing.isEmpty()) {
+ return failed(OutboundErrorCode.FIFO_DATA_MISSING,
+ "FIFO scope contains stock without inbound time", Map.of("spaceIds", missing));
+ }
+
+ List timed = new ArrayList<>();
+ for (SpaceState space : scope) {
+ String raw = context.rawInboundTime(space);
+ Instant timestamp;
+ try {
+ timestamp = OffsetDateTime.parse(raw).toInstant();
+ } catch (DateTimeParseException first) {
+ try {
+ timestamp = Instant.parse(raw);
+ } catch (DateTimeParseException second) {
+ return failed(OutboundErrorCode.FIFO_INVALID_TIME, "Stock has an invalid inbound time",
+ Map.of("spaceId", space.spaceId(), "inboundTime", raw));
+ }
+ }
+ timed.add(new TimedStock(space, timestamp, actualBlockers(space, context.spaces()).size(),
+ laneReleaseRank(space, context), efficiencyScore(space, request, context)));
+ }
+ Instant oldest = timed.stream().map(TimedStock::timestamp).min(Instant::compareTo).orElseThrow();
+ Comparator costComparator = Comparator.comparingInt(TimedStock::blockerCount)
+ .thenComparingInt(TimedStock::releaseRank)
+ .thenComparing(Comparator.comparingInt(TimedStock::efficiencyScore).reversed())
+ .thenComparing(TimedStock::timestamp)
+ .thenComparing(item -> item.space().spaceId());
+
+ TimedStock selected;
+ if (specified != null) {
+ selected = timed.stream().filter(item -> item.space().spaceId().equals(specified.spaceId()))
+ .findFirst().orElseThrow();
+ } else if (mode.equals("STRICT")) {
+ selected = timed.stream().filter(item -> item.timestamp().equals(oldest)).min(costComparator).orElseThrow();
+ } else {
+ Instant latest = oldest.plus(tolerance, ChronoUnit.MINUTES);
+ selected = timed.stream().filter(item -> !item.timestamp().isAfter(latest)).min(costComparator).orElseThrow();
+ }
+
+ int rank = 1 + (int) timed.stream().filter(item -> item.timestamp().isBefore(selected.timestamp())).count();
+ int deviation = Math.toIntExact(ChronoUnit.MINUTES.between(oldest, selected.timestamp()));
+ boolean overridden = specified != null;
+ boolean violation = overridden && selected.timestamp().isAfter(oldest);
+ Optional warning = overridden
+ ? Optional.of(new FifoWarning("FIFO_OVERRIDDEN_BY_TRAY", violation
+ ? "Specified tray is not the oldest stock; FIFO was explicitly overridden"
+ : "Specified tray is in the oldest FIFO cohort"))
+ : Optional.empty();
+ String reason = overridden
+ ? "Specified tray retained; real FIFO rank reported"
+ : mode.equals("STRICT")
+ ? "Global oldest stock selected"
+ : "Lowest handling cost selected inside the FIFO tolerance window";
+ return new Selection(selected.space(), new FifoDecision(true, mode, rank, oldest, selected.timestamp(),
+ deviation, violation, reason, warning), null);
+ }
+
+ private OutboundWorkflow buildWorkflow(OutboundRequest request, SpaceState source, List blockers,
+ List buffers, List spaces) {
+ List drafts = new ArrayList<>();
+ for (int index = 0; index < blockers.size(); index++) {
+ drafts.add(new DraftStep(id(request.requestId(), "MOVE-OUT", index), OutboundWorkflowPhase.MOVE_OUT,
+ blockers.get(index).spaceId(), buffers.get(index).spaceId()));
+ }
+ drafts.add(new DraftStep(id(request.requestId(), "OUTBOUND", 0), OutboundWorkflowPhase.OUTBOUND,
+ source.spaceId(), "DIRECT_OUT"));
+
+ List ascending = new ArrayList<>(blockers);
+ ascending.sort(Comparator.comparingInt(SpaceState::groupCoding).thenComparing(SpaceState::spaceId));
+ List compactTargets = spaces.stream()
+ .filter(space -> space.laneCode().equals(source.laneCode()))
+ .filter(space -> space.groupCoding() >= source.groupCoding())
+ .sorted(Comparator.comparingInt(SpaceState::groupCoding).thenComparing(SpaceState::spaceId))
+ .limit(blockers.size())
+ .toList();
+ for (int index = 0; index < ascending.size(); index++) {
+ SpaceState blocker = ascending.get(index);
+ int bufferIndex = blockers.indexOf(blocker);
+ String target = request.wholeTrayOrDefault() ? compactTargets.get(index).spaceId() : blocker.spaceId();
+ drafts.add(new DraftStep(id(request.requestId(), "RESTORE", index), OutboundWorkflowPhase.RESTORE,
+ buffers.get(bufferIndex).spaceId(), target));
+ }
+
+ List steps = new ArrayList<>();
+ for (int index = 0; index < drafts.size(); index++) {
+ DraftStep draft = drafts.get(index);
+ steps.add(new OutboundWorkflowStep(draft.assignmentId(), draft.phase(), draft.sourceSpaceId(),
+ draft.targetSpaceId(), index + 1, drafts.size(), index == 0 ? null : drafts.get(index - 1).assignmentId()));
+ }
+ List laneLocks = spaces.stream()
+ .filter(space -> space.laneCode().equals(source.laneCode()))
+ .sorted(Comparator.comparingInt(SpaceState::groupCoding).thenComparing(SpaceState::spaceId))
+ .map(SpaceState::spaceId)
+ .toList();
+ return new OutboundWorkflow(request.requestId(), source.spaceId(), source.laneCode(), source.floorNo(), steps,
+ laneLocks, buffers.stream().map(SpaceState::spaceId).toList(), "RELEASE_AFTER_FINAL_RESTORE");
+ }
+
+ private List actualBlockers(SpaceState source, List spaces) {
+ return spaces.stream()
+ .filter(space -> space.laneCode().equals(source.laneCode()))
+ .filter(space -> space.groupCoding() > source.groupCoding())
+ .filter(SpaceState::occupied)
+ .sorted(Comparator.comparingInt(SpaceState::groupCoding).reversed()
+ .thenComparing(SpaceState::spaceId))
+ .toList();
+ }
+
+ private List availableBuffers(int floor, List spaces) {
+ return spaces.stream()
+ .filter(space -> space.floorNo() == floor)
+ .filter(space -> space.role() == SpaceRole.RELOCATION_BUFFER)
+ .filter(SpaceState::availableEmpty)
+ .sorted(Comparator.comparing(SpaceState::spaceId))
+ .toList();
+ }
+
+ private boolean matchesRequest(SpaceState space, OutboundRequest request) {
+ if (!blank(request.trayNo())) return request.trayNo().equals(space.trayNo());
+ return request.projectNo().equals(space.projectNo())
+ && (blank(request.productId()) || request.productId().equals(space.productId()));
+ }
+
+ private int laneReleaseRank(SpaceState space, OutboundContext context) {
+ return context.capacityMode() != OutboundCapacityMode.BALANCED
+ && occupiedInLane(space, context.spaces()) == 1 ? 0 : 1;
+ }
+
+ private int occupiedInLane(SpaceState source, List spaces) {
+ return (int) spaces.stream()
+ .filter(space -> space.laneCode().equals(source.laneCode()))
+ .filter(SpaceState::occupied)
+ .count();
+ }
+
+ private int efficiencyScore(SpaceState space, OutboundRequest request, OutboundContext context) {
+ return 100 - Math.abs(space.floorNo() - request.exitFloorOrDefault()) * 12
+ - actualBlockers(space, context.spaces()).size() * 10;
+ }
+
+ private OutboundPlan error(OutboundErrorCode code, String message, Map details) {
+ return OutboundPlan.error(new OutboundError(code, message, details));
+ }
+
+ private OutboundPlan unavailableSource(SpaceState source) {
+ Map details = new HashMap<>();
+ details.put("sourceSpaceId", source.spaceId());
+ details.put("laneCode", source.laneCode());
+ if (!blank(source.trayNo())) details.put("trayNo", source.trayNo());
+ return OutboundPlan.waiting(new OutboundWaiting(OutboundWaitingCode.WAITING_CHANNEL, 0, 0,
+ "Requested stock is allocated, inspected, locked, or reserved; wait and recalculate", true,
+ details));
+ }
+
+ private Selection failed(OutboundErrorCode code, String message, Map details) {
+ return new Selection(null, null, new OutboundError(code, message, details));
+ }
+
+ private String normalizedMode(String value) {
+ return blank(value) ? "OFF" : value.trim().toUpperCase(Locale.ROOT);
+ }
+
+ private String id(String requestId, String phase, int index) {
+ return requestId + "-" + phase + "-" + String.format(Locale.ROOT, "%02d", index + 1);
+ }
+
+ private boolean blank(String value) {
+ return value == null || value.isBlank();
+ }
+
+ private record Selection(SpaceState source, FifoDecision fifo, OutboundError error) {
+ }
+
+ private record TimedStock(SpaceState space, Instant timestamp, int blockerCount, int releaseRank,
+ int efficiencyScore) {
+ }
+
+ private record DraftStep(String assignmentId, OutboundWorkflowPhase phase, String sourceSpaceId,
+ String targetSpaceId) {
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundRequest.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundRequest.java
new file mode 100644
index 0000000..64db789
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundRequest.java
@@ -0,0 +1,20 @@
+package com.ruoyi.algorithm.outbound;
+
+public record OutboundRequest(
+ String requestId,
+ String trayNo,
+ String projectNo,
+ String productId,
+ String fifoMode,
+ Integer fifoToleranceMinutes,
+ Integer exitFloor,
+ Boolean wholeTray
+) {
+ public boolean wholeTrayOrDefault() {
+ return wholeTray == null || wholeTray;
+ }
+
+ public int exitFloorOrDefault() {
+ return exitFloor == null ? 1 : exitFloor;
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWaiting.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWaiting.java
new file mode 100644
index 0000000..2d6096e
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWaiting.java
@@ -0,0 +1,16 @@
+package com.ruoyi.algorithm.outbound;
+
+import java.util.Map;
+
+public record OutboundWaiting(
+ OutboundWaitingCode code,
+ int required,
+ int available,
+ String reason,
+ boolean retryable,
+ Map details
+) {
+ public OutboundWaiting {
+ details = Map.copyOf(details);
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWaitingCode.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWaitingCode.java
new file mode 100644
index 0000000..6e12728
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWaitingCode.java
@@ -0,0 +1,6 @@
+package com.ruoyi.algorithm.outbound;
+
+public enum OutboundWaitingCode {
+ WAITING_RELOCATION_BUFFER,
+ WAITING_CHANNEL
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWorkflow.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWorkflow.java
new file mode 100644
index 0000000..30ee30e
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWorkflow.java
@@ -0,0 +1,20 @@
+package com.ruoyi.algorithm.outbound;
+
+import java.util.List;
+
+public record OutboundWorkflow(
+ String workflowId,
+ String sourceSpaceId,
+ String laneCode,
+ int floorNo,
+ List steps,
+ List lockedLaneSpaceIds,
+ List lockedBufferSpaceIds,
+ String lockReleaseRule
+) {
+ public OutboundWorkflow {
+ steps = List.copyOf(steps);
+ lockedLaneSpaceIds = List.copyOf(lockedLaneSpaceIds);
+ lockedBufferSpaceIds = List.copyOf(lockedBufferSpaceIds);
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWorkflowPhase.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWorkflowPhase.java
new file mode 100644
index 0000000..72af895
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWorkflowPhase.java
@@ -0,0 +1,7 @@
+package com.ruoyi.algorithm.outbound;
+
+public enum OutboundWorkflowPhase {
+ MOVE_OUT,
+ OUTBOUND,
+ RESTORE
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWorkflowStep.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWorkflowStep.java
new file mode 100644
index 0000000..21a6df8
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/OutboundWorkflowStep.java
@@ -0,0 +1,12 @@
+package com.ruoyi.algorithm.outbound;
+
+public record OutboundWorkflowStep(
+ String assignmentId,
+ OutboundWorkflowPhase phase,
+ String sourceSpaceId,
+ String targetSpaceId,
+ int workflowStep,
+ int workflowTotalSteps,
+ String dependsOnAssignmentId
+) {
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/TaskCountEstimate.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/TaskCountEstimate.java
new file mode 100644
index 0000000..fb91d4e
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/outbound/TaskCountEstimate.java
@@ -0,0 +1,7 @@
+package com.ruoyi.algorithm.outbound;
+
+public record TaskCountEstimate(int inbound, int outbound, int relocation, int floorChange) {
+ public static TaskCountEstimate zero() {
+ return new TaskCountEstimate(0, 0, 0, 0);
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/policy/SchedulingPolicy.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/policy/SchedulingPolicy.java
new file mode 100644
index 0000000..a8f1226
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/policy/SchedulingPolicy.java
@@ -0,0 +1,16 @@
+package com.ruoyi.algorithm.policy;
+
+public record SchedulingPolicy(
+ double capacityWeight,
+ double efficiencyWeight,
+ int warningOccupied,
+ int capacityFirstOccupied,
+ int maxOccupied,
+ int relocationBuffersPerFloor,
+ int outboundCachesPerFloor,
+ int maxTidyCandidates
+) {
+ public static SchedulingPolicy ft546Defaults() {
+ return new SchedulingPolicy(0.55, 0.45, 564, 597, 615, 4, 2, 10);
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/DecisionLogEntry.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/DecisionLogEntry.java
new file mode 100644
index 0000000..fe28bd0
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/DecisionLogEntry.java
@@ -0,0 +1,7 @@
+package com.ruoyi.algorithm.repository;
+
+import java.time.Instant;
+
+public record DecisionLogEntry(String decisionId, String warehouseId, String decisionType,
+ VersionToken inputVersion, String resultCode, String reason, Instant createdAt) {
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/DecisionLogRecord.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/DecisionLogRecord.java
new file mode 100644
index 0000000..6ec914e
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/DecisionLogRecord.java
@@ -0,0 +1,5 @@
+package com.ruoyi.algorithm.repository;
+
+public record DecisionLogRecord(String decisionId, String warehouseId, String decisionType,
+ VersionToken inputVersion, String resultCode, String reason) {
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/DeepWorkflowMutationResult.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/DeepWorkflowMutationResult.java
new file mode 100644
index 0000000..0f1dbfe
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/DeepWorkflowMutationResult.java
@@ -0,0 +1,9 @@
+package com.ruoyi.algorithm.repository;
+
+public record DeepWorkflowMutationResult(boolean changed, String reasonCode) {
+ public static DeepWorkflowMutationResult succeeded() { return new DeepWorkflowMutationResult(true, null); }
+ public static DeepWorkflowMutationResult unchanged() { return new DeepWorkflowMutationResult(false, null); }
+ public static DeepWorkflowMutationResult writesDisabled() {
+ return new DeepWorkflowMutationResult(false, "WRITES_DISABLED");
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/H2TopologyReadModel.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/H2TopologyReadModel.java
new file mode 100644
index 0000000..03d02ca
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/H2TopologyReadModel.java
@@ -0,0 +1,61 @@
+package com.ruoyi.algorithm.repository;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.Objects;
+
+final class H2TopologyReadModel {
+ private final JdbcTemplate jdbc;
+
+ H2TopologyReadModel(JdbcTemplate jdbc) {
+ this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
+ }
+
+ boolean changed(String warehouseId) {
+ String expectedMismatch = "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=? "
+ + "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), EXPECTED AS (SELECT C.CARGO_SPACE_ID AS SPACE_ID,C.WAREHOUSE_ID,"
+ + "C.GROUP_CODING_NO AS LANE_CODE,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 AS BASE_ZONE "
+ + "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=?) "
+ + "SELECT COUNT(*) FROM EXPECTED E LEFT JOIN ALG_SPACE_TOPOLOGY T ON T.SPACE_ID=E.SPACE_ID "
+ + "WHERE T.SPACE_ID IS NULL OR T.WAREHOUSE_ID<>E.WAREHOUSE_ID OR T.LANE_CODE<>E.LANE_CODE "
+ + "OR T.LANE_DEPTH<>E.LANE_DEPTH OR T.BASE_ZONE<>E.BASE_ZONE";
+ Integer mismatched = jdbc.queryForObject(expectedMismatch, Integer.class, warehouseId, warehouseId);
+ if (mismatched != null && mismatched > 0) {
+ return true;
+ }
+ Integer stale = jdbc.queryForObject("SELECT COUNT(*) FROM ALG_SPACE_TOPOLOGY T LEFT JOIN BAS_CARGO_SPACE C "
+ + "ON C.CARGO_SPACE_ID=T.SPACE_ID AND C.WAREHOUSE_ID=T.WAREHOUSE_ID "
+ + "WHERE T.WAREHOUSE_ID=? AND C.CARGO_SPACE_ID IS NULL",
+ Integer.class, warehouseId);
+ return stale != null && stale > 0;
+ }
+
+ void rebuild(String warehouseId) {
+ jdbc.update("DELETE FROM ALG_SPACE_TOPOLOGY WHERE WAREHOUSE_ID=?", warehouseId);
+ jdbc.update("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=? 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, warehouseId);
+ }
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/InboundReservation.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/InboundReservation.java
new file mode 100644
index 0000000..5df00ee
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/InboundReservation.java
@@ -0,0 +1,5 @@
+package com.ruoyi.algorithm.repository;
+
+public record InboundReservation(String reservationId, String warehouseId, String laneCode,
+ String spaceId, String projectNo, VersionToken expectedVersion) {
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/InventoryItem.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/InventoryItem.java
new file mode 100644
index 0000000..f611481
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/InventoryItem.java
@@ -0,0 +1,9 @@
+package com.ruoyi.algorithm.repository;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+
+public record InventoryItem(String stockId, String spaceId, String trayNo, String projectNo, String productNo,
+ String lotNo, String inboundTimeRaw, Instant inboundAt,
+ BigDecimal quantity, BigDecimal outboundQuantity) {
+}
diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepository.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepository.java
new file mode 100644
index 0000000..437a558
--- /dev/null
+++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepository.java
@@ -0,0 +1,845 @@
+package com.ruoyi.algorithm.repository;
+
+import com.ruoyi.algorithm.inbound.InboundPolicy;
+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.springframework.dao.DuplicateKeyException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import javax.sql.DataSource;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Types;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.EnumSet;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+public final class JdbcWarehouseRepository implements WarehouseRepository {
+ public static final int MAX_PAGE_SIZE = 200;
+
+ private final RepositoryDialect dialect;
+ private final JdbcTemplate jdbc;
+ private final NamedParameterJdbcTemplate named;
+ private final TransactionTemplate transactions;
+ private final H2TopologyReadModel h2Topology;
+ private final boolean writesEnabled;
+
+ public JdbcWarehouseRepository(DataSource dataSource, RepositoryDialect dialect) {
+ this(dataSource, dialect, true);
+ }
+
+ public JdbcWarehouseRepository(DataSource dataSource, RepositoryDialect dialect, boolean writesEnabled) {
+ this(dataSource, dialect, writesEnabled, new DataSourceTransactionManager(dataSource));
+ }
+
+ public JdbcWarehouseRepository(DataSource dataSource, RepositoryDialect dialect, boolean writesEnabled,
+ PlatformTransactionManager transactionManager) {
+ this.dialect = Objects.requireNonNull(dialect, "dialect");
+ this.jdbc = new JdbcTemplate(dataSource);
+ this.named = new NamedParameterJdbcTemplate(dataSource);
+ this.transactions = new TransactionTemplate(Objects.requireNonNull(transactionManager, "transactionManager"));
+ this.h2Topology = new H2TopologyReadModel(jdbc);
+ this.writesEnabled = writesEnabled;
+ }
+
+ @Override
+ public void synchronizeReadModel(String warehouseId) {
+ if (!writesEnabled) throw new IllegalStateException("Database writes are disabled");
+ if (blankToNull(warehouseId) == null) throw new IllegalArgumentException("warehouseId is required");
+ if (!dialect.h2Database()) {
+ jdbc.update("EXEC alg.usp_sync_inventory_snapshot @warehouse_id=?", warehouseId);
+ jdbc.update("EXEC alg.usp_rebuild_lane_state @warehouse_id=?", warehouseId);
+ return;
+ }
+ transactions.executeWithoutResult(status -> synchronizeH2ReadModel(warehouseId));
+ }
+
+ @Override
+ public void refreshChangedLanes(String warehouseId) {
+ if (!writesEnabled) throw new IllegalStateException("Database writes are disabled");
+ if (blankToNull(warehouseId) == null) throw new IllegalArgumentException("warehouseId is required");
+ if (!dialect.h2Database()) {
+ jdbc.update("EXEC alg.usp_refresh_dirty_lanes @warehouse_id=?", warehouseId);
+ return;
+ }
+ transactions.executeWithoutResult(status -> refreshChangedLanesH2(warehouseId));
+ }
+
+ private void refreshChangedLanesH2(String warehouseId) {
+ LinkedHashSet laneCodes = new LinkedHashSet<>(jdbc.queryForList(
+ "SELECT GROUP_CODING_NO FROM BAS_CARGO_SPACE WHERE WAREHOUSE_ID=? "
+ + "UNION SELECT LANE_CODE FROM ALG_LANE_STATE WHERE WAREHOUSE_ID=? ORDER BY 1",
+ String.class, warehouseId, warehouseId));
+ if (h2Topology.changed(warehouseId)) {
+ h2Topology.rebuild(warehouseId);
+ laneCodes.addAll(jdbc.queryForList("SELECT DISTINCT GROUP_CODING_NO FROM BAS_CARGO_SPACE "
+ + "WHERE WAREHOUSE_ID=? ORDER BY GROUP_CODING_NO", String.class, warehouseId));
+ jdbc.update("DELETE FROM ALG_INVENTORY_ITEM_SNAPSHOT WHERE WAREHOUSE_ID=? AND SPACE_ID NOT IN "
+ + "(SELECT CARGO_SPACE_ID FROM BAS_CARGO_SPACE WHERE WAREHOUSE_ID=?)",
+ warehouseId, warehouseId);
+ jdbc.update("DELETE FROM ALG_SPACE_INVENTORY_SUMMARY WHERE WAREHOUSE_ID=? AND SPACE_ID NOT IN "
+ + "(SELECT CARGO_SPACE_ID FROM BAS_CARGO_SPACE WHERE WAREHOUSE_ID=?)",
+ warehouseId, warehouseId);
+ }
+ List dirtyLanes = laneCodes.stream().filter(laneCode -> {
+ List stored = jdbc.queryForList("SELECT SEMANTIC_FINGERPRINT FROM ALG_LANE_STATE "
+ + "WHERE WAREHOUSE_ID=? AND LANE_CODE=?",
+ String.class, warehouseId, laneCode);
+ return stored.isEmpty() || !Objects.equals(stored.get(0),
+ h2SemanticFingerprint(warehouseId, laneCode, InventoryFingerprintSource.LIVE));
+ }).toList();
+ dirtyLanes.forEach(laneCode -> refreshH2InventoryLane(warehouseId, laneCode));
+ dirtyLanes.forEach(laneCode -> refreshH2LaneState(warehouseId, laneCode));
+ }
+
+ private void refreshH2InventoryLane(String warehouseId, String laneCode) {
+ jdbc.update("DELETE FROM ALG_SPACE_INVENTORY_SUMMARY WHERE WAREHOUSE_ID=? AND SPACE_ID IN "
+ + "(SELECT CARGO_SPACE_ID FROM BAS_CARGO_SPACE WHERE WAREHOUSE_ID=? AND GROUP_CODING_NO=?)",
+ warehouseId, warehouseId, laneCode);
+ jdbc.update("DELETE FROM ALG_INVENTORY_ITEM_SNAPSHOT WHERE WAREHOUSE_ID=? AND SPACE_ID IN "
+ + "(SELECT CARGO_SPACE_ID FROM BAS_CARGO_SPACE WHERE WAREHOUSE_ID=? AND GROUP_CODING_NO=?)",
+ warehouseId, warehouseId, laneCode);
+ jdbc.update("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 "
+ + "WHERE C.WAREHOUSE_ID=? AND C.GROUP_CODING_NO=?",
+ warehouseId, laneCode);
+ jdbc.update("INSERT INTO ALG_SPACE_INVENTORY_SUMMARY "
+ + "(WAREHOUSE_ID,SPACE_ID,STOCK_DETAIL_COUNT,STOCK_TRAY_COUNT,STOCK_PROJECT_COUNT,STOCK_PRODUCT_COUNT) "
+ + "SELECT I.WAREHOUSE_ID,I.SPACE_ID,COUNT(*),COUNT(DISTINCT NULLIF(TRIM(I.TRAY_CODE),'')),"
+ + "COUNT(DISTINCT NULLIF(TRIM(I.PROJECT_NO),'')),COUNT(DISTINCT NULLIF(TRIM(I.PRODUCT_NO),'')) "
+ + "FROM ALG_INVENTORY_ITEM_SNAPSHOT I JOIN BAS_CARGO_SPACE C ON C.CARGO_SPACE_ID=I.SPACE_ID "
+ + "AND C.WAREHOUSE_ID=I.WAREHOUSE_ID WHERE I.WAREHOUSE_ID=? AND C.GROUP_CODING_NO=? "
+ + "GROUP BY I.WAREHOUSE_ID,I.SPACE_ID",
+ warehouseId, laneCode);
+ }
+
+ private void refreshH2LaneState(String warehouseId, String laneCode) {
+ List