feat: add algorithm core for shadow observation
This commit is contained in:
@@ -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<SpaceState> laneSpaces, SchedulingPolicy policy) {
|
||||
List<SpaceState> 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<SpaceState> nextSlot = corrupt
|
||||
? Optional.empty()
|
||||
: spaces.stream().filter(SpaceState::availableEmpty).findFirst();
|
||||
int outermostOccupiedDepth = spaces.stream()
|
||||
.filter(SpaceState::occupied)
|
||||
.mapToInt(SpaceState::depthOrder)
|
||||
.max()
|
||||
.orElse(0);
|
||||
Optional<SpaceState> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ruoyi.algorithm.inbound;
|
||||
|
||||
public enum BatchLaneMode {
|
||||
OPEN,
|
||||
SEALED
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ruoyi.algorithm.inbound;
|
||||
|
||||
public enum InboundBorrowType {
|
||||
NONE,
|
||||
CACHE_BORROWED,
|
||||
RESERVE_BORROWED,
|
||||
MIXED_IN
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ruoyi.algorithm.inbound;
|
||||
|
||||
import com.ruoyi.algorithm.model.SpaceState;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record InboundContext(
|
||||
List<SpaceState> spaces,
|
||||
InboundPolicy policy,
|
||||
List<PendingOutbound> 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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ruoyi.algorithm.inbound;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public record InboundPlan(Optional<InboundAssignment> assignment, Optional<InboundRejection> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<Integer, List<Integer>> 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<Lane> lanes = buildLanes(context);
|
||||
|
||||
Optional<Candidate> 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<LadderTraceStep> 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<PendingOutbound> 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<PendingOutbound> pendingOutbounds;
|
||||
private final int occupiedCount;
|
||||
private final Map<String, Candidate> retained = new LinkedHashMap<>();
|
||||
private final java.util.Set<String> missingManualLanes;
|
||||
private long freeSinglePool;
|
||||
|
||||
private PagedSession(InboundRequest request, InboundPolicy policy,
|
||||
List<PendingOutbound> 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<SpaceState> pageSpaces) {
|
||||
InboundContext context = new InboundContext(pageSpaces, policy, pendingOutbounds, occupiedCount);
|
||||
List<Lane> lanes = buildLanes(context);
|
||||
lanes.forEach(lane -> missingManualLanes.remove(lane.code()));
|
||||
if (policy.isCommon(request.projectNo())) {
|
||||
acceptCommon(context, lanes);
|
||||
} else {
|
||||
acceptProject(context, lanes);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<InboundAssignment> currentAssignment() {
|
||||
return selected().map(candidate -> toAssignment(request, candidate));
|
||||
}
|
||||
|
||||
public java.util.Set<String> 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> 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<Lane> 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<Candidate> 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<Lane> lanes) {
|
||||
List<Lane> 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<PoolTier> 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<Lane> 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<Candidate> 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<Candidate> 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<Candidate> 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> candidate, Comparator<Candidate> comparator) {
|
||||
candidate.ifPresent(value -> retained.merge(key, value,
|
||||
(current, proposed) -> comparator.compare(proposed, current) < 0 ? proposed : current));
|
||||
}
|
||||
|
||||
private List<LadderTraceStep> 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<String> 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<Candidate> chooseProject(InboundRequest request, InboundContext context, List<Lane> lanes) {
|
||||
Optional<Candidate> 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<Candidate> chooseCommon(InboundRequest request, InboundContext context, List<Lane> lanes) {
|
||||
for (String zone : List.of("FLEX", "CACHE", "RESERVE")) {
|
||||
Optional<Candidate> 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<Candidate> 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<Candidate> 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<SpaceState> 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<Candidate> emptyLaneCandidates(InboundRequest request, InboundContext context,
|
||||
List<Lane> 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<Lane> lanes) {
|
||||
List<Lane> 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<PoolTier> 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<Candidate> 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<Candidate> bestMixed(InboundRequest request, InboundContext context, List<Lane> lanes) {
|
||||
Comparator<Candidate> 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<LadderTraceStep> traceFor(InboundRequest request, InboundContext context, List<Lane> 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<ManualLaneReservation> 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<Candidate> best(List<Candidate> candidates) {
|
||||
return candidates.stream().min(candidateComparator());
|
||||
}
|
||||
|
||||
private Comparator<Candidate> 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<Lane> buildLanes(InboundContext context) {
|
||||
Map<String, List<SpaceState>> 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<SpaceState> input, InboundContext context) {
|
||||
List<SpaceState> spaces = input.stream()
|
||||
.sorted(Comparator.comparingInt(SpaceState::depthOrder).thenComparing(SpaceState::spaceId))
|
||||
.toList();
|
||||
List<SpaceState> 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<Segment> 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<SpaceState> 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<SpaceState> spaces,
|
||||
List<SpaceState> occupied,
|
||||
List<Segment> segments,
|
||||
boolean corrupt,
|
||||
boolean outboundBlocked,
|
||||
boolean isEmptyLane,
|
||||
Optional<SpaceState> nextSlot,
|
||||
Optional<SpaceState> 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> candidate, boolean guardBlocked) {
|
||||
}
|
||||
}
|
||||
@@ -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<String> isolatedProjects,
|
||||
Set<String> commonProjectNos,
|
||||
List<ManualLaneReservation> manualReservedLanes,
|
||||
int commonSinglePoolReserve,
|
||||
Set<String> 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<String> isolatedProjects,
|
||||
Set<String> commonProjectNos, List<ManualLaneReservation> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<LadderTraceStep> ladderTrace
|
||||
) {
|
||||
public InboundRejection {
|
||||
ladderTrace = List.copyOf(ladderTrace);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ruoyi.algorithm.inbound;
|
||||
|
||||
public enum InboundRejectionCode {
|
||||
NO_FEASIBLE_LOCATION,
|
||||
NO_BATCH_COMPATIBLE_LANE
|
||||
}
|
||||
@@ -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<Integer> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ruoyi.algorithm.inbound;
|
||||
|
||||
public record LadderTraceStep(InboundLadderRung rung, String reason) {
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ruoyi.algorithm.integration;
|
||||
|
||||
public enum SchedulingDecisionStatus {
|
||||
READY,
|
||||
WAITING,
|
||||
REJECTED,
|
||||
TECHNICAL_ERROR
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.integration;
|
||||
|
||||
public enum SchedulingMode {
|
||||
LEGACY,
|
||||
SHADOW,
|
||||
ACTIVE
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ruoyi.algorithm.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public record LaneState(
|
||||
List<SpaceState> spaces,
|
||||
boolean physicallyContinuous,
|
||||
boolean corrupt,
|
||||
int segmentCount,
|
||||
Optional<String> residentProjectNo,
|
||||
Optional<String> guestProjectNo,
|
||||
Optional<SpaceState> nextSlot,
|
||||
Optional<SpaceState> outerSlot
|
||||
) {
|
||||
public LaneState {
|
||||
spaces = List.copyOf(spaces);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ruoyi.algorithm.model;
|
||||
|
||||
public enum SpaceRole {
|
||||
STORAGE,
|
||||
RELOCATION_BUFFER,
|
||||
OUTBOUND_CACHE,
|
||||
PROTECTED_CORE
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.model;
|
||||
|
||||
public enum SpaceStatus {
|
||||
EMPTY,
|
||||
OCCUPIED,
|
||||
DISABLED
|
||||
}
|
||||
@@ -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<FifoWarning> 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ruoyi.algorithm.outbound;
|
||||
|
||||
public record FifoWarning(String code, String message) {
|
||||
}
|
||||
@@ -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<String> blockerSpaceIds,
|
||||
List<String> blockingTrayNos,
|
||||
boolean laneReleasing,
|
||||
double capacityScore,
|
||||
double efficiencyScore,
|
||||
double totalScore,
|
||||
FifoDecision fifo,
|
||||
Optional<OutboundWorkflow> workflow,
|
||||
String reason
|
||||
) {
|
||||
public OutboundAssignment {
|
||||
blockerSpaceIds = List.copyOf(blockerSpaceIds);
|
||||
blockingTrayNos = List.copyOf(blockingTrayNos);
|
||||
workflow = workflow == null ? Optional.empty() : workflow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.outbound;
|
||||
|
||||
public enum OutboundCapacityMode {
|
||||
BALANCED,
|
||||
WARNING,
|
||||
CAPACITY_FIRST
|
||||
}
|
||||
@@ -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<SpaceState> spaces,
|
||||
Map<String, String> rawInboundTimes,
|
||||
OutboundCapacityMode capacityMode,
|
||||
Set<Integer> 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<SpaceState> spaces, Map<String, String> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ruoyi.algorithm.outbound;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record OutboundError(OutboundErrorCode code, String message, Map<String, Object> details) {
|
||||
public OutboundError {
|
||||
details = Map.copyOf(details);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ruoyi.algorithm.outbound;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public record OutboundPlan(
|
||||
OutboundPlanStatus status,
|
||||
Optional<OutboundAssignment> assignment,
|
||||
Optional<OutboundWaiting> waiting,
|
||||
Optional<OutboundError> 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.outbound;
|
||||
|
||||
public enum OutboundPlanStatus {
|
||||
READY,
|
||||
WAITING,
|
||||
ERROR
|
||||
}
|
||||
@@ -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<SpaceState> 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<SpaceState> 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<SpaceState> blockers = actualBlockers(source, context.spaces());
|
||||
List<SpaceState> 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<SpaceState> 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<String> blockerIds = blockers.stream().map(SpaceState::spaceId).toList();
|
||||
List<String> blockerTrays = blockers.stream().map(SpaceState::trayNo).toList();
|
||||
Map<String, Object> 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<OutboundWorkflow> 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<SpaceState> matches, OutboundRequest request, OutboundContext context) {
|
||||
Comparator<SpaceState> 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<SpaceState> 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<SpaceState> scope = context.spaces().stream()
|
||||
.filter(SpaceState::occupied)
|
||||
.filter(space -> project.equals(space.projectNo()))
|
||||
.filter(space -> blank(product) || product.equals(space.productId()))
|
||||
.toList();
|
||||
List<String> 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<TimedStock> 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<TimedStock> 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<FifoWarning> 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<SpaceState> blockers,
|
||||
List<SpaceState> buffers, List<SpaceState> spaces) {
|
||||
List<DraftStep> 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<SpaceState> ascending = new ArrayList<>(blockers);
|
||||
ascending.sort(Comparator.comparingInt(SpaceState::groupCoding).thenComparing(SpaceState::spaceId));
|
||||
List<SpaceState> 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<OutboundWorkflowStep> 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<String> 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<SpaceState> actualBlockers(SpaceState source, List<SpaceState> 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<SpaceState> availableBuffers(int floor, List<SpaceState> 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<SpaceState> 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<String, Object> details) {
|
||||
return OutboundPlan.error(new OutboundError(code, message, details));
|
||||
}
|
||||
|
||||
private OutboundPlan unavailableSource(SpaceState source) {
|
||||
Map<String, Object> 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<String, Object> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> details
|
||||
) {
|
||||
public OutboundWaiting {
|
||||
details = Map.copyOf(details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ruoyi.algorithm.outbound;
|
||||
|
||||
public enum OutboundWaitingCode {
|
||||
WAITING_RELOCATION_BUFFER,
|
||||
WAITING_CHANNEL
|
||||
}
|
||||
@@ -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<OutboundWorkflowStep> steps,
|
||||
List<String> lockedLaneSpaceIds,
|
||||
List<String> lockedBufferSpaceIds,
|
||||
String lockReleaseRule
|
||||
) {
|
||||
public OutboundWorkflow {
|
||||
steps = List.copyOf(steps);
|
||||
lockedLaneSpaceIds = List.copyOf(lockedLaneSpaceIds);
|
||||
lockedBufferSpaceIds = List.copyOf(lockedBufferSpaceIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.outbound;
|
||||
|
||||
public enum OutboundWorkflowPhase {
|
||||
MOVE_OUT,
|
||||
OUTBOUND,
|
||||
RESTORE
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public record DecisionLogRecord(String decisionId, String warehouseId, String decisionType,
|
||||
VersionToken inputVersion, String resultCode, String reason) {
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public record InboundReservation(String reservationId, String warehouseId, String laneCode,
|
||||
String spaceId, String projectNo, VersionToken expectedVersion) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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<String> 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<String> dirtyLanes = laneCodes.stream().filter(laneCode -> {
|
||||
List<String> 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<Map<String, Object>> source = jdbc.queryForList("SELECT WAREHOUSE_ID,LANE_CODE,COUNT(*) SPACE_COUNT,"
|
||||
+ "SUM(CASE WHEN SPACE_STATUS='2' OR STOCK_DETAIL_COUNT>0 THEN 1 ELSE 0 END) OCCUPIED_COUNT,"
|
||||
+ "SUM(CASE WHEN INBOUND_AVAILABLE=TRUE THEN 1 ELSE 0 END) AVAILABLE_COUNT,"
|
||||
+ "MAX(CASE WHEN PENDING_OUTBOUND=TRUE THEN 1 ELSE 0 END) PENDING_OUTBOUND,"
|
||||
+ "MAX(CASE WHEN HAS_GAP=TRUE THEN 1 ELSE 0 END) HAS_GAP "
|
||||
+ "FROM ALG_V_SPACE_STATE WHERE WAREHOUSE_ID=? AND LANE_CODE=? "
|
||||
+ "GROUP BY WAREHOUSE_ID,LANE_CODE",
|
||||
warehouseId, laneCode);
|
||||
if (source.isEmpty()) {
|
||||
jdbc.update("DELETE FROM ALG_LANE_STATE WHERE WAREHOUSE_ID=? AND LANE_CODE=?",
|
||||
warehouseId, laneCode);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> lane = source.get(0);
|
||||
int spaces = number(lane, "SPACE_COUNT");
|
||||
int occupied = number(lane, "OCCUPIED_COUNT");
|
||||
int available = number(lane, "AVAILABLE_COUNT");
|
||||
boolean pending = number(lane, "PENDING_OUTBOUND") != 0;
|
||||
boolean gap = number(lane, "HAS_GAP") != 0;
|
||||
String fingerprint = h2SemanticFingerprint(warehouseId, laneCode,
|
||||
InventoryFingerprintSource.SNAPSHOT);
|
||||
List<Map<String, Object>> existing = jdbc.queryForList(
|
||||
"SELECT SPACE_COUNT,OCCUPIED_COUNT,AVAILABLE_COUNT,PENDING_OUTBOUND,HAS_GAP,"
|
||||
+ "SEMANTIC_FINGERPRINT FROM ALG_LANE_STATE WHERE WAREHOUSE_ID=? AND LANE_CODE=?",
|
||||
warehouseId, laneCode);
|
||||
if (existing.isEmpty()) {
|
||||
jdbc.update("INSERT INTO ALG_LANE_STATE (WAREHOUSE_ID,LANE_CODE,SPACE_COUNT,OCCUPIED_COUNT,"
|
||||
+ "AVAILABLE_COUNT,PENDING_OUTBOUND,HAS_GAP,SEMANTIC_FINGERPRINT,STATE_VERSION) "
|
||||
+ "VALUES (?,?,?,?,?,?,?,?,1)",
|
||||
warehouseId, laneCode, spaces, occupied, available, pending, gap, fingerprint);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> old = existing.get(0);
|
||||
boolean changed = number(old, "SPACE_COUNT") != spaces || number(old, "OCCUPIED_COUNT") != occupied
|
||||
|| number(old, "AVAILABLE_COUNT") != available
|
||||
|| booleanValue(old.get("PENDING_OUTBOUND")) != pending
|
||||
|| booleanValue(old.get("HAS_GAP")) != gap
|
||||
|| !Objects.equals(old.get("SEMANTIC_FINGERPRINT"), fingerprint);
|
||||
if (changed) {
|
||||
jdbc.update("UPDATE ALG_LANE_STATE SET SPACE_COUNT=?,OCCUPIED_COUNT=?,AVAILABLE_COUNT=?,"
|
||||
+ "PENDING_OUTBOUND=?,HAS_GAP=?,SEMANTIC_FINGERPRINT=?,STATE_VERSION=STATE_VERSION+1,"
|
||||
+ "REBUILT_AT=CURRENT_TIMESTAMP WHERE WAREHOUSE_ID=? AND LANE_CODE=?",
|
||||
spaces, occupied, available, pending, gap, fingerprint, warehouseId, laneCode);
|
||||
}
|
||||
}
|
||||
|
||||
private void synchronizeH2ReadModel(String warehouseId) {
|
||||
jdbc.update("DELETE FROM ALG_SPACE_INVENTORY_SUMMARY WHERE WAREHOUSE_ID = ?", warehouseId);
|
||||
jdbc.update("DELETE FROM ALG_INVENTORY_ITEM_SNAPSHOT WHERE WAREHOUSE_ID = ?", warehouseId);
|
||||
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 = ?", warehouseId);
|
||||
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 WAREHOUSE_ID,SPACE_ID,COUNT(*),COUNT(DISTINCT NULLIF(TRIM(TRAY_CODE),'')),"
|
||||
+ "COUNT(DISTINCT NULLIF(TRIM(PROJECT_NO),'')),COUNT(DISTINCT NULLIF(TRIM(PRODUCT_NO),'')) "
|
||||
+ "FROM ALG_INVENTORY_ITEM_SNAPSHOT WHERE WAREHOUSE_ID = ? GROUP BY WAREHOUSE_ID,SPACE_ID",
|
||||
warehouseId);
|
||||
|
||||
List<Map<String, Object>> source = jdbc.queryForList("SELECT WAREHOUSE_ID,LANE_CODE,COUNT(*) SPACE_COUNT,"
|
||||
+ "SUM(CASE WHEN SPACE_STATUS='2' OR STOCK_DETAIL_COUNT>0 THEN 1 ELSE 0 END) OCCUPIED_COUNT,"
|
||||
+ "SUM(CASE WHEN INBOUND_AVAILABLE=TRUE THEN 1 ELSE 0 END) AVAILABLE_COUNT,"
|
||||
+ "MAX(CASE WHEN PENDING_OUTBOUND=TRUE THEN 1 ELSE 0 END) PENDING_OUTBOUND,"
|
||||
+ "MAX(CASE WHEN HAS_GAP=TRUE THEN 1 ELSE 0 END) HAS_GAP "
|
||||
+ "FROM ALG_V_SPACE_STATE WHERE WAREHOUSE_ID=? GROUP BY WAREHOUSE_ID,LANE_CODE ORDER BY LANE_CODE",
|
||||
warehouseId);
|
||||
Set<String> currentLanes = new LinkedHashSet<>();
|
||||
for (Map<String, Object> lane : source) {
|
||||
String laneCode = lane.get("LANE_CODE").toString();
|
||||
currentLanes.add(laneCode);
|
||||
String fingerprint = h2SemanticFingerprint(warehouseId, laneCode,
|
||||
InventoryFingerprintSource.SNAPSHOT);
|
||||
List<Map<String, Object>> existing = jdbc.queryForList("SELECT SPACE_COUNT,OCCUPIED_COUNT,AVAILABLE_COUNT,"
|
||||
+ "PENDING_OUTBOUND,HAS_GAP,SEMANTIC_FINGERPRINT,STATE_VERSION FROM ALG_LANE_STATE "
|
||||
+ "WHERE WAREHOUSE_ID=? AND LANE_CODE=?", warehouseId, laneCode);
|
||||
int spaces = number(lane, "SPACE_COUNT");
|
||||
int occupied = number(lane, "OCCUPIED_COUNT");
|
||||
int available = number(lane, "AVAILABLE_COUNT");
|
||||
boolean pending = number(lane, "PENDING_OUTBOUND") != 0;
|
||||
boolean gap = number(lane, "HAS_GAP") != 0;
|
||||
if (existing.isEmpty()) {
|
||||
jdbc.update("INSERT INTO ALG_LANE_STATE (WAREHOUSE_ID,LANE_CODE,SPACE_COUNT,OCCUPIED_COUNT,"
|
||||
+ "AVAILABLE_COUNT,PENDING_OUTBOUND,HAS_GAP,SEMANTIC_FINGERPRINT,STATE_VERSION) "
|
||||
+ "VALUES (?,?,?,?,?,?,?,?,1)", warehouseId, laneCode, spaces, occupied, available,
|
||||
pending, gap, fingerprint);
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> old = existing.get(0);
|
||||
boolean changed = number(old, "SPACE_COUNT") != spaces || number(old, "OCCUPIED_COUNT") != occupied
|
||||
|| number(old, "AVAILABLE_COUNT") != available
|
||||
|| booleanValue(old.get("PENDING_OUTBOUND")) != pending
|
||||
|| booleanValue(old.get("HAS_GAP")) != gap
|
||||
|| !fingerprint.equals(Objects.toString(old.get("SEMANTIC_FINGERPRINT"), ""));
|
||||
if (changed) {
|
||||
jdbc.update("UPDATE ALG_LANE_STATE SET SPACE_COUNT=?,OCCUPIED_COUNT=?,AVAILABLE_COUNT=?,"
|
||||
+ "PENDING_OUTBOUND=?,HAS_GAP=?,SEMANTIC_FINGERPRINT=?,STATE_VERSION=STATE_VERSION+1,"
|
||||
+ "REBUILT_AT=CURRENT_TIMESTAMP WHERE WAREHOUSE_ID=? AND LANE_CODE=?",
|
||||
spaces, occupied, available, pending, gap, fingerprint, warehouseId, laneCode);
|
||||
}
|
||||
}
|
||||
jdbc.queryForList("SELECT LANE_CODE FROM ALG_LANE_STATE WHERE WAREHOUSE_ID=?", String.class, warehouseId)
|
||||
.stream().filter(lane -> !currentLanes.contains(lane))
|
||||
.forEach(lane -> jdbc.update("DELETE FROM ALG_LANE_STATE WHERE WAREHOUSE_ID=? AND LANE_CODE=?",
|
||||
warehouseId, lane));
|
||||
}
|
||||
|
||||
private String h2SemanticFingerprint(String warehouseId, String laneCode,
|
||||
InventoryFingerprintSource inventorySource) {
|
||||
StringBuilder payload = new StringBuilder();
|
||||
appendRows(payload, "SELECT C.CARGO_SPACE_ID,C.STATUS,C.LOCKED,C.TRAY_CODE,C.GROUP_CODING,"
|
||||
+ "C.THE_PLATOON,C.THE_COLUMN,C.THE_FLOOR,T.LANE_DEPTH,T.BASE_ZONE,COALESCE(R.ROLE,'STORAGE') "
|
||||
+ "FROM BAS_CARGO_SPACE C LEFT JOIN ALG_SPACE_TOPOLOGY T ON T.SPACE_ID=C.CARGO_SPACE_ID "
|
||||
+ "LEFT JOIN ALG_SPACE_ROLE R ON R.SPACE_ID=C.CARGO_SPACE_ID "
|
||||
+ "WHERE C.WAREHOUSE_ID=? AND C.GROUP_CODING_NO=? "
|
||||
+ "ORDER BY CASE WHEN C.GROUP_CODING REGEXP '^[0-9]+$' THEN CAST(C.GROUP_CODING AS INT) END,C.CARGO_SPACE_ID",
|
||||
warehouseId, laneCode);
|
||||
String inventorySql = inventorySource == InventoryFingerprintSource.SNAPSHOT
|
||||
? "SELECT S.STOCK_ID,S.SPACE_ID,S.TRAY_CODE,S.PROJECT_NO,S.PRODUCT_NO,S.LOT_NO,"
|
||||
+ "S.INBOUND_TIME_RAW,CAST(S.QUANTITY AS DECIMAL(38,6)),CAST(S.OUT_QUANTITY AS DECIMAL(38,6)) "
|
||||
+ "FROM ALG_INVENTORY_ITEM_SNAPSHOT S JOIN BAS_CARGO_SPACE C "
|
||||
+ "ON C.CARGO_SPACE_ID=S.SPACE_ID AND C.WAREHOUSE_ID=S.WAREHOUSE_ID "
|
||||
+ "WHERE C.WAREHOUSE_ID=? AND C.GROUP_CODING_NO=? ORDER BY S.SPACE_ID,S.STOCK_ID"
|
||||
: "SELECT S.STOCK_ID,S.CARGO_SPACE_ID,S.TRAY_CODE,S.SPARE4,S.PRODUCT_ID,S.LOT_NO,"
|
||||
+ "S.IN_DATE,CAST(S.QUANTITY AS DECIMAL(38,6)),CAST(S.OUT_QUANTITY AS DECIMAL(38,6)) "
|
||||
+ "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=? ORDER BY S.CARGO_SPACE_ID,S.STOCK_ID";
|
||||
appendRows(payload, inventorySql, warehouseId, laneCode);
|
||||
appendRows(payload, "SELECT JOB_ID,TASK_NO,TRAY_CODE,SOURCE_SPACE_ID,TARGET_CARGO_SPACE_ID,STATUS,IN_OR_OUT,CREATE_TIME_RAW "
|
||||
+ "FROM ALG_V_ACTIVE_JOB WHERE WAREHOUSE_ID=? AND SOURCE_LANE_CODE=? ORDER BY JOB_ID",
|
||||
warehouseId, laneCode);
|
||||
appendRows(payload, "SELECT PROJECT_NO,IS_ISOLATED,IS_COMMON,ALLOW_MIXED FROM ALG_PROJECT_POLICY ORDER BY PROJECT_NO");
|
||||
appendRows(payload, "SELECT WAREHOUSE_ID,TARGET_FILL_RATE,FIFO_TOLERANCE_HOURS FROM ALG_WAREHOUSE_POLICY WHERE WAREHOUSE_ID=?",
|
||||
warehouseId);
|
||||
appendRows(payload, "SELECT RESERVATION_ID,SPACE_ID,PROJECT_NO,STATUS FROM ALG_RESERVATION "
|
||||
+ "WHERE WAREHOUSE_ID=? AND LANE_CODE=? AND STATUS='ACTIVE' ORDER BY SPACE_ID,RESERVATION_ID",
|
||||
warehouseId, laneCode);
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(payload.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendRows(StringBuilder payload, String sql, Object... arguments) {
|
||||
payload.append('|');
|
||||
jdbc.query(sql, rs -> {
|
||||
int columns = rs.getMetaData().getColumnCount();
|
||||
payload.append('[');
|
||||
for (int index = 1; index <= columns; index++) {
|
||||
String value = rs.getString(index);
|
||||
payload.append(value == null ? -1 : value.length()).append(':');
|
||||
if (value != null) payload.append(value);
|
||||
payload.append(';');
|
||||
}
|
||||
payload.append(']');
|
||||
}, arguments);
|
||||
}
|
||||
|
||||
private int number(Map<String, Object> row, String column) {
|
||||
Object value = row.get(column);
|
||||
return value == null ? 0 : ((Number) value).intValue();
|
||||
}
|
||||
|
||||
private boolean booleanValue(Object value) {
|
||||
return value instanceof Boolean bool ? bool : value instanceof Number number && number.intValue() != 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WarehouseSummary loadSummary(String warehouseId) {
|
||||
String sql = "SELECT COUNT(DISTINCT lane_code) AS lane_count, COUNT(*) AS space_count, "
|
||||
+ "SUM(CASE WHEN space_status = '2' OR stock_detail_count > 0 THEN 1 ELSE 0 END) AS occupied_count, "
|
||||
+ "SUM(CASE WHEN inbound_available = " + dialect.trueLiteral() + " THEN 1 ELSE 0 END) AS available_count, "
|
||||
+ "COUNT(DISTINCT CASE WHEN pending_outbound = " + dialect.trueLiteral() + " THEN lane_code END) AS pending_lane_count, "
|
||||
+ "SUM(CASE WHEN tray_conflict = " + dialect.trueLiteral() + " OR project_conflict = " + dialect.trueLiteral()
|
||||
+ " OR product_conflict = " + dialect.trueLiteral() + " THEN 1 ELSE 0 END) AS conflict_count, "
|
||||
+ "SUM(CASE WHEN reserved = " + dialect.trueLiteral() + " THEN 1 ELSE 0 END) AS reserved_count "
|
||||
+ "FROM " + dialect.spaceStateView() + " WHERE warehouse_id = ?";
|
||||
return jdbc.queryForObject(sql, (rs, row) -> new WarehouseSummary(warehouseId,
|
||||
rs.getInt("lane_count"), rs.getInt("space_count"), rs.getInt("occupied_count"),
|
||||
rs.getInt("available_count"), rs.getInt("pending_lane_count"), rs.getInt("conflict_count"),
|
||||
rs.getInt("reserved_count")), warehouseId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LanePage pageCandidateLanes(String warehouseId, String afterLaneCode, int pageSize) {
|
||||
return pageLanes(warehouseId, afterLaneCode, pageSize,
|
||||
" AND available_count > 0 AND pending_outbound = " + dialect.falseLiteral()
|
||||
+ " AND has_gap = " + dialect.falseLiteral());
|
||||
}
|
||||
|
||||
@Override
|
||||
public LanePage pageWarehouseLanes(String warehouseId, String afterLaneCode, int pageSize) {
|
||||
return pageLanes(warehouseId, afterLaneCode, pageSize, "");
|
||||
}
|
||||
|
||||
private LanePage pageLanes(String warehouseId, String afterLaneCode, int requestedSize, String hardFacts) {
|
||||
int size = Math.max(1, Math.min(MAX_PAGE_SIZE, requestedSize));
|
||||
String projection = "warehouse_id, lane_code, space_count, occupied_count, available_count, "
|
||||
+ "pending_outbound, has_gap, state_version";
|
||||
String sql = "SELECT TOP (:limit) " + projection + " FROM " + dialect.laneState()
|
||||
+ " WHERE warehouse_id = :warehouse AND lane_code > :after" + hardFacts
|
||||
+ " ORDER BY lane_code";
|
||||
List<LaneCandidate> lanes = named.query(sql, new MapSqlParameterSource()
|
||||
.addValue("warehouse", warehouseId).addValue("after", blankToEmpty(afterLaneCode))
|
||||
.addValue("limit", size), laneCandidateMapper());
|
||||
String next = lanes.size() == size ? lanes.get(lanes.size() - 1).laneCode() : null;
|
||||
return new LanePage(lanes, next);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LaneSnapshot> loadLaneSnapshots(String warehouseId, Collection<String> laneCodes) {
|
||||
LinkedHashSet<String> codes = laneCodes == null ? new LinkedHashSet<>()
|
||||
: laneCodes.stream().filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
if (codes.isEmpty()) return List.of();
|
||||
MapSqlParameterSource parameters = new MapSqlParameterSource()
|
||||
.addValue("warehouse", warehouseId).addValue("lanes", codes);
|
||||
Map<String, VersionToken> versions = new LinkedHashMap<>();
|
||||
named.query("SELECT lane_code, state_version FROM " + dialect.laneState()
|
||||
+ " WHERE warehouse_id = :warehouse AND lane_code IN (:lanes)", parameters,
|
||||
(rs, row) -> Map.entry(rs.getString("lane_code"),
|
||||
VersionToken.fromJdbc(rs.getObject("state_version"))))
|
||||
.forEach(entry -> versions.put(entry.getKey(), entry.getValue()));
|
||||
|
||||
List<RawSpace> rawSpaces = named.query("SELECT space_id, warehouse_id, lane_code, area_id, zone_code, roadway_id, "
|
||||
+ "platoon, column_no, floor_no, depth, space_status, locked, space_tray_code, role, "
|
||||
+ "tray_conflict, project_conflict, product_conflict, pending_outbound, reserved, has_gap, inbound_available "
|
||||
+ "FROM " + dialect.spaceStateView()
|
||||
+ " WHERE warehouse_id = :warehouse AND lane_code IN (:lanes) "
|
||||
+ "ORDER BY lane_code, depth, space_id", parameters, rawSpaceMapper());
|
||||
List<InventoryItem> inventory = named.query("SELECT i.stock_id, i.space_id, i.tray_code, i.project_no, "
|
||||
+ "i.product_no, i.lot_no, i.inbound_time_raw, i.inbound_at, i.quantity, i.out_quantity "
|
||||
+ "FROM " + dialect.inventoryView() + " i JOIN " + dialect.spaceStateView() + " s ON s.space_id = i.space_id "
|
||||
+ "WHERE s.warehouse_id = :warehouse AND s.lane_code IN (:lanes) "
|
||||
+ "ORDER BY i.space_id, i.stock_id", parameters, inventoryMapper());
|
||||
Map<String, List<InventoryItem>> inventoryBySpace = inventory.stream()
|
||||
.collect(Collectors.groupingBy(InventoryItem::spaceId, LinkedHashMap::new, Collectors.toList()));
|
||||
Map<String, List<RawSpace>> byLane = rawSpaces.stream()
|
||||
.collect(Collectors.groupingBy(RawSpace::laneCode, LinkedHashMap::new, Collectors.toList()));
|
||||
List<LaneSnapshot> result = new ArrayList<>();
|
||||
byLane.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
|
||||
int capacity = entry.getValue().size();
|
||||
List<SpaceSnapshot> spaces = entry.getValue().stream()
|
||||
.map(raw -> mapSpace(raw, capacity, versions.get(entry.getKey()),
|
||||
inventoryBySpace.getOrDefault(raw.spaceId(), List.of())))
|
||||
.sorted(Comparator.comparingInt((SpaceSnapshot value) -> value.state().depthOrder())
|
||||
.thenComparing(SpaceSnapshot::spaceId)).toList();
|
||||
result.add(new LaneSnapshot(warehouseId, entry.getKey(), versions.get(entry.getKey()), spaces));
|
||||
});
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SpaceSnapshot> loadRelocationBuffers(String warehouseId, Set<Integer> floors) {
|
||||
if (floors == null || floors.isEmpty()) return List.of();
|
||||
List<String> laneCodes = named.queryForList("SELECT DISTINCT lane_code FROM " + dialect.spaceStateView()
|
||||
+ " WHERE warehouse_id = :warehouse AND role = 'RELOCATION_BUFFER' AND floor_no IN (:floors) "
|
||||
+ "ORDER BY lane_code", new MapSqlParameterSource().addValue("warehouse", warehouseId)
|
||||
.addValue("floors", floors), String.class);
|
||||
return loadLaneSnapshots(warehouseId, laneCodes).stream().flatMap(lane -> lane.spaces().stream())
|
||||
.filter(space -> space.state().role() == SpaceRole.RELOCATION_BUFFER)
|
||||
.filter(space -> floors.contains(space.state().floorNo())).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Integer> loadActiveDeepWorkflowFloors(String warehouseId, String excludingRequestId) {
|
||||
String sql = "SELECT floor_no FROM " + dialect.deepWorkflowLock()
|
||||
+ " WHERE warehouse_id = ? AND status = 'ACTIVE' AND (? IS NULL OR request_id <> ?) ORDER BY floor_no";
|
||||
String excluded = blankToNull(excludingRequestId);
|
||||
return new LinkedHashSet<>(jdbc.queryForList(sql, Integer.class, warehouseId, excluded, excluded));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean acquireDeepWorkflowLock(String requestId, String warehouseId, int floorNo) {
|
||||
return acquireDeepWorkflowLockResult(requestId, warehouseId, floorNo).changed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DeepWorkflowMutationResult acquireDeepWorkflowLockResult(String requestId, String warehouseId, int floorNo) {
|
||||
if (!writesEnabled) return DeepWorkflowMutationResult.writesDisabled();
|
||||
validateWorkflowLock(requestId, warehouseId, floorNo);
|
||||
boolean acquired = dialect.h2Database() ? acquireDeepWorkflowH2(requestId, warehouseId, floorNo)
|
||||
: named.queryForObject("EXEC " + dialect.acquireDeepWorkflowProcedure()
|
||||
+ " @request_id=:request, @warehouse_id=:warehouse, @floor_no=:floor",
|
||||
new MapSqlParameterSource().addValue("request", requestId).addValue("warehouse", warehouseId)
|
||||
.addValue("floor", floorNo), (rs, row) -> rs.getBoolean("acquired"));
|
||||
return acquired ? DeepWorkflowMutationResult.succeeded() : DeepWorkflowMutationResult.unchanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean releaseDeepWorkflowLock(String requestId, String warehouseId, int floorNo) {
|
||||
return releaseDeepWorkflowLockResult(requestId, warehouseId, floorNo).changed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DeepWorkflowMutationResult releaseDeepWorkflowLockResult(String requestId, String warehouseId, int floorNo) {
|
||||
if (!writesEnabled) return DeepWorkflowMutationResult.writesDisabled();
|
||||
validateWorkflowLock(requestId, warehouseId, floorNo);
|
||||
boolean released = dialect.h2Database() ? releaseDeepWorkflowH2(requestId, warehouseId, floorNo)
|
||||
: named.queryForObject("EXEC " + dialect.releaseDeepWorkflowProcedure()
|
||||
+ " @request_id=:request, @warehouse_id=:warehouse, @floor_no=:floor",
|
||||
new MapSqlParameterSource().addValue("request", requestId).addValue("warehouse", warehouseId)
|
||||
.addValue("floor", floorNo), (rs, row) -> rs.getBoolean("released"));
|
||||
return released ? DeepWorkflowMutationResult.succeeded() : DeepWorkflowMutationResult.unchanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PendingOutboundJob> loadPendingOutboundJobs(String warehouseId) {
|
||||
String sql = "SELECT job_id, task_no, tray_code, project_no, product_no, source_space_id, warehouse_id, source_lane_code, "
|
||||
+ "source_resolution, status, in_or_out, create_time_raw FROM " + dialect.activeJobView()
|
||||
+ " WHERE warehouse_id = ? AND in_or_out >= 20 ORDER BY job_id";
|
||||
return jdbc.query(sql, (rs, row) -> new PendingOutboundJob(rs.getString("job_id"), rs.getString("task_no"),
|
||||
rs.getString("tray_code"), rs.getString("project_no"), rs.getString("product_no"),
|
||||
rs.getString("source_space_id"), rs.getString("warehouse_id"), rs.getString("source_lane_code"),
|
||||
rs.getString("source_resolution"), rs.getString("status"), rs.getInt("in_or_out"),
|
||||
rs.getString("create_time_raw")), warehouseId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<OutboundStockMatch> searchOutboundStock(String warehouseId, OutboundStockSearch search) {
|
||||
String predicate = search.trayNo() != null ? "i.tray_code = :tray"
|
||||
: "i.project_no = :project AND (:product IS NULL OR i.product_no = :product)";
|
||||
MapSqlParameterSource parameters = new MapSqlParameterSource().addValue("warehouse", warehouseId)
|
||||
.addValue("tray", search.trayNo()).addValue("project", search.projectNo())
|
||||
.addValue("product", blankToNull(search.productNo()));
|
||||
String sql = "SELECT i.stock_id, i.space_id, s.lane_code, s.floor_no, i.tray_code, i.project_no, "
|
||||
+ "i.product_no, i.lot_no, i.inbound_time_raw, i.inbound_at FROM " + dialect.inventoryView()
|
||||
+ " i JOIN " + dialect.spaceStateView() + " s ON s.space_id = i.space_id "
|
||||
+ "WHERE s.warehouse_id = :warehouse AND " + predicate
|
||||
+ " ORDER BY i.inbound_at, i.inbound_time_raw, i.stock_id";
|
||||
List<OutboundStockMatch> matches = named.query(sql, parameters, (rs, row) -> new OutboundStockMatch(
|
||||
rs.getString("stock_id"), rs.getString("space_id"), rs.getString("lane_code"),
|
||||
rs.getInt("floor_no"), rs.getString("tray_code"), rs.getString("project_no"),
|
||||
rs.getString("product_no"), rs.getString("lot_no"), rs.getString("inbound_time_raw"),
|
||||
instant(rs, "inbound_at")));
|
||||
if (search.trayNo() == null) return matches;
|
||||
|
||||
List<OutboundStockMatch> cargoMatches = named.query("SELECT space_id, lane_code, floor_no, space_tray_code FROM "
|
||||
+ dialect.spaceStateView() + " WHERE warehouse_id = :warehouse "
|
||||
+ "AND LTRIM(RTRIM(space_tray_code)) = :tray ORDER BY space_id",
|
||||
parameters, (rs, row) -> new OutboundStockMatch("CARGO_SPACE:" + rs.getString("space_id"),
|
||||
rs.getString("space_id"), rs.getString("lane_code"), rs.getInt("floor_no"),
|
||||
rs.getString("space_tray_code"), null, null, null, null, null));
|
||||
Set<String> matchedSpaces = matches.stream().map(OutboundStockMatch::spaceId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
List<OutboundStockMatch> combined = new ArrayList<>(matches);
|
||||
cargoMatches.stream().filter(match -> matchedSpaces.add(match.spaceId())).forEach(combined::add);
|
||||
return List.copyOf(combined);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countProjectPallets(String warehouseId, String projectNo) {
|
||||
String usableTray = "CASE WHEN s.stock_tray_count = 1 THEN NULLIF(LTRIM(RTRIM(i.tray_code)), '') "
|
||||
+ "WHEN s.stock_tray_count = 0 THEN NULLIF(LTRIM(RTRIM(s.space_tray_code)), '') END";
|
||||
Integer count = named.queryForObject("SELECT COUNT(DISTINCT " + usableTray + ") FROM "
|
||||
+ dialect.spaceStateView() + " s JOIN " + dialect.inventoryView()
|
||||
+ " i ON i.space_id = s.space_id WHERE s.warehouse_id = :warehouse "
|
||||
+ "AND NULLIF(LTRIM(RTRIM(i.project_no)), '') = :project AND s.space_status = '2' "
|
||||
+ "AND s.role = 'STORAGE' AND s.depth IS NOT NULL AND s.pending_outbound = "
|
||||
+ dialect.falseLiteral() + " AND s.reserved = " + dialect.falseLiteral()
|
||||
+ " AND s.has_gap = " + dialect.falseLiteral() + " AND s.tray_conflict = "
|
||||
+ dialect.falseLiteral() + " AND s.project_conflict = " + dialect.falseLiteral()
|
||||
+ " AND (NULLIF(LTRIM(RTRIM(s.locked)), '') IS NULL OR LTRIM(RTRIM(s.locked)) IN ('0', 'N')) "
|
||||
+ "AND (NULLIF(LTRIM(RTRIM(s.space_tray_code)), '') IS NULL "
|
||||
+ "OR NULLIF(LTRIM(RTRIM(i.tray_code)), '') IS NULL "
|
||||
+ "OR LTRIM(RTRIM(s.space_tray_code)) = LTRIM(RTRIM(i.tray_code)))",
|
||||
new MapSqlParameterSource().addValue("warehouse", warehouseId).addValue("project", projectNo),
|
||||
Integer.class);
|
||||
return count == null ? 0 : count;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RepositoryPolicy loadPolicy(String warehouseId) {
|
||||
List<Map<String, Object>> warehouses = jdbc.queryForList("SELECT target_fill_rate, fifo_tolerance_hours FROM "
|
||||
+ dialect.warehousePolicy() + " WHERE warehouse_id = ?", warehouseId);
|
||||
double targetFill = warehouses.isEmpty() || warehouses.get(0).get("TARGET_FILL_RATE") == null ? 0.0
|
||||
: ((Number) warehouses.get(0).get("TARGET_FILL_RATE")).doubleValue();
|
||||
int toleranceMinutes = warehouses.isEmpty() || warehouses.get(0).get("FIFO_TOLERANCE_HOURS") == null ? 0
|
||||
: ((Number) warehouses.get(0).get("FIFO_TOLERANCE_HOURS")).intValue() * 60;
|
||||
Set<String> isolated = new LinkedHashSet<>();
|
||||
Set<String> common = new LinkedHashSet<>();
|
||||
Set<String> mixedDenied = new LinkedHashSet<>();
|
||||
jdbc.query("SELECT project_no, is_isolated, is_common, allow_mixed FROM " + dialect.projectPolicy(), rs -> {
|
||||
if (rs.getBoolean("is_isolated")) isolated.add(rs.getString("project_no"));
|
||||
if (rs.getBoolean("is_common")) common.add(rs.getString("project_no"));
|
||||
if (!rs.getBoolean("allow_mixed")) mixedDenied.add(rs.getString("project_no"));
|
||||
});
|
||||
if (common.isEmpty()) common.add("COMMON");
|
||||
SchedulingPolicy scheduling = SchedulingPolicy.ft546Defaults();
|
||||
return new RepositoryPolicy(new InboundPolicy(scheduling, isolated, common, List.of(), 0, mixedDenied),
|
||||
scheduling, toleranceMinutes, targetFill);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DecisionLogEntry insertDecision(DecisionLogRecord record) {
|
||||
String id = blankToNull(record.decisionId()) == null ? UUID.randomUUID().toString() : record.decisionId();
|
||||
if (!writesEnabled) {
|
||||
return new DecisionLogEntry(id, record.warehouseId(), record.decisionType(), record.inputVersion(),
|
||||
record.resultCode(), abbreviate(record.reason(), 2000), Instant.now());
|
||||
}
|
||||
named.update("INSERT INTO " + dialect.decisionLog()
|
||||
+ " (decision_id, warehouse_id, decision_type, input_version, result_code, reason) "
|
||||
+ "VALUES (:id, :warehouse, :type, :version, :result, :reason)",
|
||||
new MapSqlParameterSource().addValue("id", sqlId(id)).addValue("warehouse", record.warehouseId())
|
||||
.addValue("type", record.decisionType()).addValue("version", versionValue(record.inputVersion()),
|
||||
decisionVersionSqlType(dialect))
|
||||
.addValue("result", record.resultCode()).addValue("reason", abbreviate(record.reason(), 2000)));
|
||||
return findDecision(id).orElseThrow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DecisionLogEntry> findDecision(String decisionId) {
|
||||
List<DecisionLogEntry> values = jdbc.query("SELECT decision_id, warehouse_id, decision_type, input_version, "
|
||||
+ "result_code, reason, created_at FROM " + dialect.decisionLog() + " WHERE decision_id = ?",
|
||||
(rs, row) -> new DecisionLogEntry(rs.getString("decision_id"), rs.getString("warehouse_id"),
|
||||
rs.getString("decision_type"), VersionToken.fromJdbc(rs.getObject("input_version")),
|
||||
rs.getString("result_code"), rs.getString("reason"), instant(rs, "created_at")), sqlId(decisionId));
|
||||
return values.stream().findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReservationResult tryReserveInbound(InboundReservation reservation) {
|
||||
if (!writesEnabled) return ReservationResult.conflict("WRITES_DISABLED", reservation.reservationId());
|
||||
sqlId(reservation.reservationId());
|
||||
if (reservation.expectedVersion() == null) {
|
||||
return ReservationResult.conflict("LANE_VERSION_MISMATCH", reservation.reservationId());
|
||||
}
|
||||
return dialect.h2Database() ? reserveH2(reservation) : reserveSqlServer(reservation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReservationResult releaseReservation(String warehouseId, String reservationId) {
|
||||
if (!writesEnabled) return ReservationResult.conflict("WRITES_DISABLED", reservationId);
|
||||
return dialect.h2Database() ? releaseH2(warehouseId, reservationId) : releaseSqlServer(warehouseId, reservationId);
|
||||
}
|
||||
|
||||
private ReservationResult reserveH2(InboundReservation reservation) {
|
||||
return transactions.execute(status -> {
|
||||
List<LaneReservationState> values = jdbc.query("SELECT state_version, semantic_fingerprint FROM " + dialect.laneState()
|
||||
+ " WHERE warehouse_id = ? AND lane_code = ? FOR UPDATE",
|
||||
(rs, row) -> new LaneReservationState(VersionToken.fromJdbc(rs.getObject("state_version")),
|
||||
rs.getString("semantic_fingerprint")),
|
||||
reservation.warehouseId(), reservation.laneCode());
|
||||
if (values.isEmpty() || !reservation.expectedVersion().equals(values.get(0).version())) {
|
||||
return ReservationResult.conflict("LANE_VERSION_MISMATCH", reservation.reservationId());
|
||||
}
|
||||
String storedFingerprint = values.get(0).semanticFingerprint();
|
||||
if (storedFingerprint != null && !storedFingerprint.isBlank()
|
||||
&& !storedFingerprint.equals(h2SemanticFingerprint(
|
||||
reservation.warehouseId(), reservation.laneCode(), InventoryFingerprintSource.LIVE))) {
|
||||
return ReservationResult.conflict("LANE_VERSION_MISMATCH", reservation.reservationId());
|
||||
}
|
||||
Integer pending = jdbc.queryForObject("SELECT COUNT(*) FROM " + dialect.activeJobView()
|
||||
+ " WHERE warehouse_id = ? AND in_or_out >= 20 AND source_lane_code = ?",
|
||||
Integer.class, reservation.warehouseId(), reservation.laneCode());
|
||||
if (pending != null && pending > 0) {
|
||||
return ReservationResult.conflict("PENDING_OUTBOUND", reservation.reservationId());
|
||||
}
|
||||
Integer active = jdbc.queryForObject("SELECT COUNT(*) FROM " + dialect.reservation()
|
||||
+ " WHERE space_id = ? AND status = 'ACTIVE'", Integer.class, reservation.spaceId());
|
||||
if (active != null && active > 0) {
|
||||
return ReservationResult.conflict("ACTIVE_RESERVATION_EXISTS", reservation.reservationId());
|
||||
}
|
||||
Integer eligible = jdbc.queryForObject("SELECT COUNT(*) FROM " + dialect.spaceStateView()
|
||||
+ " WHERE warehouse_id = ? AND lane_code = ? AND space_id = ? AND inbound_available = TRUE",
|
||||
Integer.class, reservation.warehouseId(), reservation.laneCode(), reservation.spaceId());
|
||||
if (eligible == null || eligible == 0) {
|
||||
return ReservationResult.conflict("SPACE_NOT_ELIGIBLE", reservation.reservationId());
|
||||
}
|
||||
try {
|
||||
jdbc.update("INSERT INTO " + dialect.reservation()
|
||||
+ " (reservation_id, warehouse_id, lane_code, space_id, project_no, status) "
|
||||
+ "VALUES (?, ?, ?, ?, ?, 'ACTIVE')", sqlId(reservation.reservationId()),
|
||||
reservation.warehouseId(), reservation.laneCode(), reservation.spaceId(), reservation.projectNo());
|
||||
} catch (DuplicateKeyException exception) {
|
||||
return ReservationResult.conflict("ACTIVE_RESERVATION_EXISTS", reservation.reservationId());
|
||||
}
|
||||
rebuildH2Lane(reservation.warehouseId(), reservation.laneCode());
|
||||
return ReservationResult.reserved(reservation.reservationId());
|
||||
});
|
||||
}
|
||||
|
||||
private boolean acquireDeepWorkflowH2(String requestId, String warehouseId, int floorNo) {
|
||||
return Boolean.TRUE.equals(transactions.execute(status -> {
|
||||
List<String> owners = jdbc.query("SELECT request_id FROM " + dialect.deepWorkflowLock()
|
||||
+ " WHERE warehouse_id = ? AND floor_no = ? AND status = 'ACTIVE' FOR UPDATE",
|
||||
(rs, row) -> rs.getString("request_id"), warehouseId, floorNo);
|
||||
if (!owners.isEmpty()) return requestId.equals(owners.get(0));
|
||||
try {
|
||||
jdbc.update("INSERT INTO " + dialect.deepWorkflowLock()
|
||||
+ " (lock_id, request_id, warehouse_id, floor_no, status) VALUES (?, ?, ?, ?, 'ACTIVE')",
|
||||
UUID.randomUUID().toString(), requestId, warehouseId, floorNo);
|
||||
return true;
|
||||
} catch (DuplicateKeyException exception) {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private boolean releaseDeepWorkflowH2(String requestId, String warehouseId, int floorNo) {
|
||||
return Boolean.TRUE.equals(transactions.execute(status -> jdbc.update("UPDATE " + dialect.deepWorkflowLock()
|
||||
+ " SET status = 'RELEASED', released_at = CURRENT_TIMESTAMP WHERE request_id = ? "
|
||||
+ "AND warehouse_id = ? AND floor_no = ? AND status = 'ACTIVE'",
|
||||
requestId, warehouseId, floorNo) == 1));
|
||||
}
|
||||
|
||||
private ReservationResult releaseH2(String warehouseId, String reservationId) {
|
||||
return transactions.execute(status -> {
|
||||
Object reservationKey = sqlId(reservationId);
|
||||
List<Map<String, Object>> rows = jdbc.queryForList("SELECT warehouse_id, lane_code FROM "
|
||||
+ dialect.reservation() + " WHERE reservation_id = ? AND warehouse_id = ? AND status = 'ACTIVE' FOR UPDATE",
|
||||
reservationKey, warehouseId);
|
||||
if (rows.isEmpty()) return ReservationResult.notFound(reservationId);
|
||||
jdbc.update("UPDATE " + dialect.reservation()
|
||||
+ " SET status = 'RELEASED', released_at = CURRENT_TIMESTAMP WHERE reservation_id = ? AND warehouse_id = ?",
|
||||
reservationKey, warehouseId);
|
||||
String warehouse = Objects.toString(rows.get(0).get("WAREHOUSE_ID"), null);
|
||||
String lane = Objects.toString(rows.get(0).get("LANE_CODE"), null);
|
||||
rebuildH2Lane(warehouse, lane);
|
||||
return ReservationResult.released(reservationId);
|
||||
});
|
||||
}
|
||||
|
||||
private void rebuildH2Lane(String warehouseId, String laneCode) {
|
||||
String fingerprint = h2SemanticFingerprint(warehouseId, laneCode,
|
||||
InventoryFingerprintSource.SNAPSHOT);
|
||||
jdbc.update("UPDATE " + dialect.laneState() + " SET "
|
||||
+ "space_count = (SELECT COUNT(*) FROM " + dialect.spaceStateView() + " WHERE warehouse_id = ? AND lane_code = ?), "
|
||||
+ "occupied_count = (SELECT COUNT(*) FROM " + dialect.spaceStateView() + " WHERE warehouse_id = ? AND lane_code = ? AND (space_status = '2' OR stock_detail_count > 0)), "
|
||||
+ "available_count = (SELECT COUNT(*) FROM " + dialect.spaceStateView() + " WHERE warehouse_id = ? AND lane_code = ? AND inbound_available = TRUE), "
|
||||
+ "pending_outbound = CASE WHEN EXISTS (SELECT 1 FROM " + dialect.spaceStateView() + " WHERE warehouse_id = ? AND lane_code = ? AND pending_outbound = TRUE) THEN TRUE ELSE FALSE END, "
|
||||
+ "has_gap = CASE WHEN EXISTS (SELECT 1 FROM " + dialect.spaceStateView() + " WHERE warehouse_id = ? AND lane_code = ? AND has_gap = TRUE) THEN TRUE ELSE FALSE END, "
|
||||
+ "semantic_fingerprint = ?, state_version = state_version + 1, rebuilt_at = CURRENT_TIMESTAMP "
|
||||
+ "WHERE warehouse_id = ? AND lane_code = ?",
|
||||
warehouseId, laneCode, warehouseId, laneCode, warehouseId, laneCode,
|
||||
warehouseId, laneCode, warehouseId, laneCode, fingerprint, warehouseId, laneCode);
|
||||
}
|
||||
|
||||
private ReservationResult reserveSqlServer(InboundReservation reservation) {
|
||||
String sql = "EXEC " + dialect.tryReserveProcedure()
|
||||
+ " @warehouse_id=:warehouse, @lane_code=:lane, @space_id=:space, @project_no=:project, "
|
||||
+ "@expected_state_version=:version, @reservation_id=:reservation";
|
||||
return named.queryForObject(sql, new MapSqlParameterSource().addValue("warehouse", reservation.warehouseId())
|
||||
.addValue("lane", reservation.laneCode()).addValue("space", reservation.spaceId())
|
||||
.addValue("project", reservation.projectNo()).addValue("version", versionValue(reservation.expectedVersion()))
|
||||
.addValue("reservation", sqlId(reservation.reservationId())), (rs, row) -> reservationResult(rs));
|
||||
}
|
||||
|
||||
private ReservationResult releaseSqlServer(String warehouseId, String reservationId) {
|
||||
return named.queryForObject("EXEC " + dialect.releaseProcedure()
|
||||
+ " @warehouse_id=:warehouse, @reservation_id=:reservation",
|
||||
new MapSqlParameterSource().addValue("warehouse", warehouseId)
|
||||
.addValue("reservation", sqlId(reservationId)), (rs, row) -> reservationResult(rs));
|
||||
}
|
||||
|
||||
private ReservationResult reservationResult(ResultSet rs) throws SQLException {
|
||||
String result = rs.getString("result");
|
||||
String id = rs.getString("reservation_id");
|
||||
return switch (result) {
|
||||
case "RESERVED" -> ReservationResult.reserved(id);
|
||||
case "RELEASED" -> ReservationResult.released(id);
|
||||
case "NOT_FOUND" -> ReservationResult.notFound(id);
|
||||
default -> ReservationResult.conflict(rs.getString("reason"), id);
|
||||
};
|
||||
}
|
||||
|
||||
private SpaceSnapshot mapSpace(RawSpace raw, int capacity, VersionToken version, List<InventoryItem> inventory) {
|
||||
EnumSet<SpaceConflict> conflicts = EnumSet.noneOf(SpaceConflict.class);
|
||||
if (raw.trayConflict()) conflicts.add(SpaceConflict.TRAY);
|
||||
if (raw.projectConflict()) conflicts.add(SpaceConflict.PROJECT);
|
||||
String inventoryTray = unique(inventory, InventoryItem::trayNo);
|
||||
String tray = blankToNull(raw.spaceTrayNo()) != null ? raw.spaceTrayNo() : inventoryTray;
|
||||
if (blankToNull(raw.spaceTrayNo()) != null && inventoryTray != null && !raw.spaceTrayNo().equals(inventoryTray)) {
|
||||
conflicts.add(SpaceConflict.SPACE_TRAY_MISMATCH);
|
||||
}
|
||||
String project = unique(inventory, InventoryItem::projectNo);
|
||||
String product = unique(inventory, InventoryItem::productNo);
|
||||
String lot = unique(inventory, InventoryItem::lotNo);
|
||||
Instant inboundAt = inventory.stream().map(InventoryItem::inboundAt).filter(Objects::nonNull)
|
||||
.min(Comparator.naturalOrder()).orElse(null);
|
||||
boolean invalid = locked(raw.locked()) || raw.reserved() || raw.hasGap() || raw.depth() == null
|
||||
|| !conflicts.isEmpty() || ("1".equals(raw.spaceStatus()) && (!inventory.isEmpty() || tray != null));
|
||||
SpaceStatus status = invalid ? SpaceStatus.DISABLED : switch (Objects.toString(raw.spaceStatus(), "")) {
|
||||
case "1" -> SpaceStatus.EMPTY;
|
||||
case "2" -> SpaceStatus.OCCUPIED;
|
||||
default -> SpaceStatus.DISABLED;
|
||||
};
|
||||
SpaceState state = new SpaceState(raw.spaceId(), raw.zoneCode(), raw.laneCode(),
|
||||
raw.depth() == null ? 0 : raw.depth(), capacity, raw.floorNo(), raw.columnNo(),
|
||||
raw.depth() == null ? 0 : raw.depth(), raw.platoon() == null ? null : raw.platoon().toString(),
|
||||
mapRole(raw.role()), status,
|
||||
raw.pendingOutbound(), !conflicts.isEmpty(), tray, project, product, lot, inboundAt, null,
|
||||
raw.reserved(), version == null ? null : version.externalForm(), !inventory.isEmpty());
|
||||
String diagnostic = invalid ? diagnostic(raw, conflicts) : null;
|
||||
return new SpaceSnapshot(state, inventory, conflicts, raw.inboundAvailable(), raw.hasGap(), diagnostic);
|
||||
}
|
||||
|
||||
private String diagnostic(RawSpace raw, Set<SpaceConflict> conflicts) {
|
||||
if (!conflicts.isEmpty()) return "INVENTORY_CONFLICT:" + conflicts;
|
||||
if (raw.reserved()) return "RESERVED";
|
||||
if (raw.hasGap()) return "LANE_GAP";
|
||||
if (raw.depth() == null) return "INVALID_TOPOLOGY";
|
||||
if (locked(raw.locked())) return "LOCKED";
|
||||
return "NON_AVAILABLE_STATUS:" + raw.spaceStatus();
|
||||
}
|
||||
|
||||
private SpaceRole mapRole(String role) {
|
||||
return switch (role) {
|
||||
case "STORAGE" -> SpaceRole.STORAGE;
|
||||
case "RELOCATION_BUFFER" -> SpaceRole.RELOCATION_BUFFER;
|
||||
case "OUTBOUND_BUFFER" -> SpaceRole.OUTBOUND_CACHE;
|
||||
case "PROTECTED_CORE" -> SpaceRole.PROTECTED_CORE;
|
||||
default -> throw new IllegalStateException("Unknown space role: " + role);
|
||||
};
|
||||
}
|
||||
|
||||
private RowMapper<LaneCandidate> laneCandidateMapper() {
|
||||
return (rs, row) -> new LaneCandidate(rs.getString("warehouse_id"), rs.getString("lane_code"),
|
||||
rs.getInt("space_count"), rs.getInt("occupied_count"), rs.getInt("available_count"),
|
||||
rs.getBoolean("pending_outbound"), rs.getBoolean("has_gap"),
|
||||
VersionToken.fromJdbc(rs.getObject("state_version")));
|
||||
}
|
||||
|
||||
private RowMapper<RawSpace> rawSpaceMapper() {
|
||||
return (rs, row) -> new RawSpace(rs.getString("space_id"), rs.getString("lane_code"),
|
||||
rs.getString("area_id"), rs.getString("zone_code"), rs.getString("roadway_id"),
|
||||
nullableInt(rs, "platoon"), rs.getInt("column_no"), rs.getInt("floor_no"),
|
||||
nullableInt(rs, "depth"), rs.getString("space_status"),
|
||||
rs.getString("locked"), rs.getString("space_tray_code"), rs.getString("role"),
|
||||
rs.getBoolean("tray_conflict"), rs.getBoolean("project_conflict"),
|
||||
rs.getBoolean("product_conflict"), rs.getBoolean("pending_outbound"),
|
||||
rs.getBoolean("reserved"), rs.getBoolean("has_gap"), rs.getBoolean("inbound_available"));
|
||||
}
|
||||
|
||||
private RowMapper<InventoryItem> inventoryMapper() {
|
||||
return (rs, row) -> new InventoryItem(rs.getString("stock_id"), rs.getString("space_id"),
|
||||
rs.getString("tray_code"), rs.getString("project_no"), rs.getString("product_no"),
|
||||
rs.getString("lot_no"), rs.getString("inbound_time_raw"), instant(rs, "inbound_at"),
|
||||
rs.getBigDecimal("quantity"), rs.getBigDecimal("out_quantity"));
|
||||
}
|
||||
|
||||
private Object sqlId(String id) {
|
||||
UUID value = UUID.fromString(id);
|
||||
return dialect.h2Database() ? value.toString() : value;
|
||||
}
|
||||
private void validateWorkflowLock(String requestId, String warehouseId, int floorNo) {
|
||||
if (blankToNull(requestId) == null) throw new IllegalArgumentException("requestId is required");
|
||||
if (blankToNull(warehouseId) == null) throw new IllegalArgumentException("warehouseId is required");
|
||||
if (floorNo <= 0) throw new IllegalArgumentException("floorNo must be positive");
|
||||
}
|
||||
private Object versionValue(VersionToken token) { return token == null ? null : token.jdbcValue(); }
|
||||
|
||||
static int decisionVersionSqlType(RepositoryDialect dialect) {
|
||||
return dialect.h2Database() ? Types.BIGINT : Types.BINARY;
|
||||
}
|
||||
private String abbreviate(String value, int maximum) {
|
||||
return value == null || value.length() <= maximum ? value : value.substring(0, maximum);
|
||||
}
|
||||
private boolean locked(String value) {
|
||||
String normalized = blankToNull(value);
|
||||
return normalized != null && !"0".equalsIgnoreCase(normalized) && !"N".equalsIgnoreCase(normalized);
|
||||
}
|
||||
private String blankToEmpty(String value) { return value == null ? "" : value; }
|
||||
private String blankToNull(String value) { return value == null || value.isBlank() ? null : value; }
|
||||
private Integer nullableInt(ResultSet rs, String column) throws SQLException {
|
||||
int value = rs.getInt(column);
|
||||
return rs.wasNull() ? null : value;
|
||||
}
|
||||
private Instant instant(ResultSet rs, String column) throws SQLException {
|
||||
Timestamp timestamp = rs.getTimestamp(column);
|
||||
return timestamp == null ? null : timestamp.toInstant();
|
||||
}
|
||||
private <T> String unique(List<T> values, Function<T, String> extractor) {
|
||||
Set<String> distinct = values.stream().map(extractor).map(this::blankToNull)
|
||||
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
return distinct.size() == 1 ? distinct.iterator().next() : null;
|
||||
}
|
||||
private <T> long distinctCount(List<T> values, Function<T, String> extractor) {
|
||||
return values.stream().map(extractor).map(this::blankToNull).filter(Objects::nonNull).distinct().count();
|
||||
}
|
||||
|
||||
private record RawSpace(String spaceId, String laneCode, String areaId, String zoneCode, String roadwayId,
|
||||
Integer platoon, int columnNo, int floorNo, Integer depth, String spaceStatus,
|
||||
String locked, String spaceTrayNo,
|
||||
String role, boolean trayConflict, boolean projectConflict, boolean productConflict,
|
||||
boolean pendingOutbound, boolean reserved, boolean hasGap, boolean inboundAvailable) {
|
||||
}
|
||||
|
||||
private record LaneReservationState(VersionToken version, String semanticFingerprint) {
|
||||
}
|
||||
|
||||
private enum InventoryFingerprintSource {
|
||||
SNAPSHOT,
|
||||
LIVE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public record LaneCandidate(String warehouseId, String laneCode, int spaceCount, int occupiedCount,
|
||||
int availableCount, boolean pendingOutbound, boolean hasGap, VersionToken version) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record LanePage(List<LaneCandidate> lanes, String nextLaneCode) {
|
||||
public LanePage { lanes = List.copyOf(lanes); }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
import com.ruoyi.algorithm.model.SpaceState;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record LaneSnapshot(String warehouseId, String laneCode, VersionToken version, List<SpaceSnapshot> spaces) {
|
||||
public LaneSnapshot { spaces = List.copyOf(spaces); }
|
||||
public List<SpaceState> spaceStates() { return spaces.stream().map(SpaceSnapshot::state).toList(); }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record OutboundStockMatch(String stockId, String spaceId, String laneCode, int floorNo, String trayNo,
|
||||
String projectNo, String productNo, String lotNo,
|
||||
String inboundTimeRaw, Instant inboundAt) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public record OutboundStockSearch(String trayNo, String projectNo, String productNo) {
|
||||
public OutboundStockSearch {
|
||||
boolean hasTray = trayNo != null && !trayNo.isBlank();
|
||||
boolean hasProject = projectNo != null && !projectNo.isBlank();
|
||||
if (hasTray == hasProject) throw new IllegalArgumentException("Specify exact tray or project, but not both");
|
||||
}
|
||||
public static OutboundStockSearch exactTray(String trayNo) { return new OutboundStockSearch(trayNo, null, null); }
|
||||
public static OutboundStockSearch project(String projectNo, String productNo) {
|
||||
return new OutboundStockSearch(null, projectNo, productNo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public record PendingOutboundJob(String jobId, String taskNo, String trayNo, String projectNo,
|
||||
String productNo, String sourceSpaceId, String warehouseId,
|
||||
String sourceLaneCode, String sourceResolution, String status,
|
||||
int direction, String createTimeRaw) {
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public final class RepositoryDialect {
|
||||
private final boolean h2Database;
|
||||
private final String laneState;
|
||||
private final String spaceStateView;
|
||||
private final String inventoryView;
|
||||
private final String activeJobView;
|
||||
private final String warehousePolicy;
|
||||
private final String projectPolicy;
|
||||
private final String reservation;
|
||||
private final String decisionLog;
|
||||
private final String deepWorkflowLock;
|
||||
private final String tryReserveProcedure;
|
||||
private final String releaseProcedure;
|
||||
private final String acquireDeepWorkflowProcedure;
|
||||
private final String releaseDeepWorkflowProcedure;
|
||||
private final String trueLiteral;
|
||||
private final String falseLiteral;
|
||||
|
||||
private RepositoryDialect(boolean h2Database, String laneState, String spaceStateView, String inventoryView,
|
||||
String activeJobView, String warehousePolicy, String projectPolicy,
|
||||
String reservation, String decisionLog, String deepWorkflowLock,
|
||||
String tryReserveProcedure, String releaseProcedure,
|
||||
String acquireDeepWorkflowProcedure, String releaseDeepWorkflowProcedure,
|
||||
String trueLiteral, String falseLiteral) {
|
||||
this.h2Database = h2Database;
|
||||
this.laneState = laneState;
|
||||
this.spaceStateView = spaceStateView;
|
||||
this.inventoryView = inventoryView;
|
||||
this.activeJobView = activeJobView;
|
||||
this.warehousePolicy = warehousePolicy;
|
||||
this.projectPolicy = projectPolicy;
|
||||
this.reservation = reservation;
|
||||
this.decisionLog = decisionLog;
|
||||
this.deepWorkflowLock = deepWorkflowLock;
|
||||
this.tryReserveProcedure = tryReserveProcedure;
|
||||
this.releaseProcedure = releaseProcedure;
|
||||
this.acquireDeepWorkflowProcedure = acquireDeepWorkflowProcedure;
|
||||
this.releaseDeepWorkflowProcedure = releaseDeepWorkflowProcedure;
|
||||
this.trueLiteral = trueLiteral;
|
||||
this.falseLiteral = falseLiteral;
|
||||
}
|
||||
public static RepositoryDialect h2() {
|
||||
return new RepositoryDialect(true, "ALG_LANE_STATE", "ALG_V_SPACE_STATE", "ALG_V_INVENTORY_ITEM",
|
||||
"ALG_V_ACTIVE_JOB", "ALG_WAREHOUSE_POLICY", "ALG_PROJECT_POLICY", "ALG_RESERVATION",
|
||||
"ALG_DECISION_LOG", "ALG_DEEP_WORKFLOW_LOCK", null, null, null, null, "TRUE", "FALSE");
|
||||
}
|
||||
|
||||
public static RepositoryDialect sqlServer() {
|
||||
return new RepositoryDialect(false, "alg.lane_state", "alg.v_space_state", "alg.v_inventory_item",
|
||||
"alg.v_active_job", "alg.warehouse_policy", "alg.project_policy", "alg.reservation",
|
||||
"alg.decision_log", "alg.deep_workflow_lock", "alg.usp_try_reserve_inbound",
|
||||
"alg.usp_release_reservation", "alg.usp_acquire_deep_workflow_lock",
|
||||
"alg.usp_release_deep_workflow_lock", "1", "0");
|
||||
}
|
||||
|
||||
public boolean h2Database() { return h2Database; }
|
||||
public String laneState() { return laneState; }
|
||||
public String spaceStateView() { return spaceStateView; }
|
||||
public String inventoryView() { return inventoryView; }
|
||||
public String activeJobView() { return activeJobView; }
|
||||
public String warehousePolicy() { return warehousePolicy; }
|
||||
public String projectPolicy() { return projectPolicy; }
|
||||
public String reservation() { return reservation; }
|
||||
public String decisionLog() { return decisionLog; }
|
||||
public String deepWorkflowLock() { return deepWorkflowLock; }
|
||||
public String tryReserveProcedure() { return tryReserveProcedure; }
|
||||
public String releaseProcedure() { return releaseProcedure; }
|
||||
public String acquireDeepWorkflowProcedure() { return acquireDeepWorkflowProcedure; }
|
||||
public String releaseDeepWorkflowProcedure() { return releaseDeepWorkflowProcedure; }
|
||||
public String trueLiteral() { return trueLiteral; }
|
||||
public String falseLiteral() { return falseLiteral; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
import com.ruoyi.algorithm.inbound.InboundPolicy;
|
||||
import com.ruoyi.algorithm.policy.SchedulingPolicy;
|
||||
|
||||
public record RepositoryPolicy(InboundPolicy inboundPolicy, SchedulingPolicy schedulingPolicy,
|
||||
int fifoToleranceMinutes, double targetFillRate) {
|
||||
public static RepositoryPolicy defaults() {
|
||||
return new RepositoryPolicy(InboundPolicy.defaults(), SchedulingPolicy.ft546Defaults(), 0, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public record ReservationResult(ReservationStatus status, String reasonCode, String reservationId) {
|
||||
public static ReservationResult reserved(String id) { return new ReservationResult(ReservationStatus.RESERVED, null, id); }
|
||||
public static ReservationResult conflict(String reason, String id) { return new ReservationResult(ReservationStatus.CONFLICT, reason, id); }
|
||||
public static ReservationResult released(String id) { return new ReservationResult(ReservationStatus.RELEASED, null, id); }
|
||||
public static ReservationResult notFound(String id) { return new ReservationResult(ReservationStatus.NOT_FOUND, "NOT_FOUND", id); }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public enum ReservationStatus { RESERVED, CONFLICT, RELEASED, NOT_FOUND }
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public enum SpaceConflict { TRAY, PROJECT, PRODUCT, LOT, SPACE_TRAY_MISMATCH }
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
import com.ruoyi.algorithm.model.SpaceState;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public record SpaceSnapshot(SpaceState state, List<InventoryItem> inventory, Set<SpaceConflict> conflicts,
|
||||
boolean inboundAvailable, boolean hasGap, String diagnostic) {
|
||||
public SpaceSnapshot {
|
||||
inventory = List.copyOf(inventory);
|
||||
conflicts = Set.copyOf(conflicts);
|
||||
}
|
||||
public String spaceId() { return state.spaceId(); }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class VersionToken {
|
||||
public enum Kind { NUMERIC, BINARY }
|
||||
|
||||
private final Kind kind;
|
||||
private final Long numeric;
|
||||
private final byte[] binary;
|
||||
|
||||
private VersionToken(Kind kind, Long numeric, byte[] binary) {
|
||||
this.kind = kind;
|
||||
this.numeric = numeric;
|
||||
this.binary = binary == null ? null : binary.clone();
|
||||
}
|
||||
|
||||
public static VersionToken numeric(long value) {
|
||||
return new VersionToken(Kind.NUMERIC, value, null);
|
||||
}
|
||||
|
||||
public static VersionToken binary(byte[] value) {
|
||||
Objects.requireNonNull(value, "value");
|
||||
return new VersionToken(Kind.BINARY, null, value);
|
||||
}
|
||||
|
||||
public static VersionToken fromJdbc(Object value) {
|
||||
if (value == null) return null;
|
||||
if (value instanceof byte[] bytes) return binary(bytes);
|
||||
if (value instanceof Number number) return numeric(number.longValue());
|
||||
throw new IllegalArgumentException("Unsupported version token type: " + value.getClass().getName());
|
||||
}
|
||||
|
||||
public Kind kind() { return kind; }
|
||||
public Object jdbcValue() { return kind == Kind.BINARY ? binary.clone() : numeric; }
|
||||
public String externalForm() {
|
||||
return kind == Kind.BINARY ? Base64.getEncoder().encodeToString(binary) : Long.toString(numeric);
|
||||
}
|
||||
|
||||
@Override public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
if (!(other instanceof VersionToken token) || kind != token.kind) return false;
|
||||
return Objects.equals(numeric, token.numeric) && Arrays.equals(binary, token.binary);
|
||||
}
|
||||
|
||||
@Override public int hashCode() { return 31 * Objects.hash(kind, numeric) + Arrays.hashCode(binary); }
|
||||
@Override public String toString() { return externalForm(); }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
public interface WarehouseRepository {
|
||||
void synchronizeReadModel(String warehouseId);
|
||||
|
||||
default void refreshChangedLanes(String warehouseId) {
|
||||
synchronizeReadModel(warehouseId);
|
||||
}
|
||||
|
||||
WarehouseSummary loadSummary(String warehouseId);
|
||||
LanePage pageCandidateLanes(String warehouseId, String afterLaneCode, int pageSize);
|
||||
LanePage pageWarehouseLanes(String warehouseId, String afterLaneCode, int pageSize);
|
||||
List<LaneSnapshot> loadLaneSnapshots(String warehouseId, Collection<String> laneCodes);
|
||||
List<SpaceSnapshot> loadRelocationBuffers(String warehouseId, Set<Integer> floors);
|
||||
Set<Integer> loadActiveDeepWorkflowFloors(String warehouseId, String excludingRequestId);
|
||||
boolean acquireDeepWorkflowLock(String requestId, String warehouseId, int floorNo);
|
||||
boolean releaseDeepWorkflowLock(String requestId, String warehouseId, int floorNo);
|
||||
default DeepWorkflowMutationResult acquireDeepWorkflowLockResult(String requestId, String warehouseId, int floorNo) {
|
||||
return acquireDeepWorkflowLock(requestId, warehouseId, floorNo)
|
||||
? DeepWorkflowMutationResult.succeeded() : DeepWorkflowMutationResult.unchanged();
|
||||
}
|
||||
default DeepWorkflowMutationResult releaseDeepWorkflowLockResult(String requestId, String warehouseId, int floorNo) {
|
||||
return releaseDeepWorkflowLock(requestId, warehouseId, floorNo)
|
||||
? DeepWorkflowMutationResult.succeeded() : DeepWorkflowMutationResult.unchanged();
|
||||
}
|
||||
List<PendingOutboundJob> loadPendingOutboundJobs(String warehouseId);
|
||||
List<OutboundStockMatch> searchOutboundStock(String warehouseId, OutboundStockSearch search);
|
||||
int countProjectPallets(String warehouseId, String projectNo);
|
||||
RepositoryPolicy loadPolicy(String warehouseId);
|
||||
DecisionLogEntry insertDecision(DecisionLogRecord record);
|
||||
Optional<DecisionLogEntry> findDecision(String decisionId);
|
||||
ReservationResult tryReserveInbound(InboundReservation reservation);
|
||||
ReservationResult releaseReservation(String warehouseId, String reservationId);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public record WarehouseSummary(String warehouseId, int laneCount, int spaceCount, int occupiedCount,
|
||||
int availableCount, int pendingLaneCount, int conflictSpaceCount,
|
||||
int reservedSpaceCount) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.service;
|
||||
|
||||
import com.ruoyi.algorithm.inbound.InboundPlan;
|
||||
import com.ruoyi.algorithm.repository.VersionToken;
|
||||
|
||||
public record InboundPlanningResult(InboundPlan plan, VersionToken laneVersion) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ruoyi.algorithm.service;
|
||||
|
||||
import com.ruoyi.algorithm.repository.ReservationResult;
|
||||
|
||||
public record InboundReservationOutcome(String resultCode, ReservationResult reservation) {
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package com.ruoyi.algorithm.service;
|
||||
|
||||
import com.ruoyi.algorithm.inbound.InboundContext;
|
||||
import com.ruoyi.algorithm.inbound.InboundPlan;
|
||||
import com.ruoyi.algorithm.inbound.InboundPlanner;
|
||||
import com.ruoyi.algorithm.inbound.InboundRequest;
|
||||
import com.ruoyi.algorithm.inbound.PendingOutbound;
|
||||
import com.ruoyi.algorithm.model.SpaceState;
|
||||
import com.ruoyi.algorithm.outbound.OutboundCapacityMode;
|
||||
import com.ruoyi.algorithm.outbound.OutboundContext;
|
||||
import com.ruoyi.algorithm.outbound.OutboundError;
|
||||
import com.ruoyi.algorithm.outbound.OutboundErrorCode;
|
||||
import com.ruoyi.algorithm.outbound.OutboundPlan;
|
||||
import com.ruoyi.algorithm.outbound.OutboundPlanner;
|
||||
import com.ruoyi.algorithm.outbound.OutboundRequest;
|
||||
import com.ruoyi.algorithm.tidy.TidyPendingOutbound;
|
||||
import com.ruoyi.algorithm.tidy.TidyPlan;
|
||||
import com.ruoyi.algorithm.tidy.TidyPlanner;
|
||||
import com.ruoyi.algorithm.tidy.TidyRequest;
|
||||
import com.ruoyi.algorithm.repository.DecisionLogRecord;
|
||||
import com.ruoyi.algorithm.repository.DeepWorkflowMutationResult;
|
||||
import com.ruoyi.algorithm.repository.InboundReservation;
|
||||
import com.ruoyi.algorithm.repository.LaneCandidate;
|
||||
import com.ruoyi.algorithm.repository.LanePage;
|
||||
import com.ruoyi.algorithm.repository.LaneSnapshot;
|
||||
import com.ruoyi.algorithm.repository.OutboundStockMatch;
|
||||
import com.ruoyi.algorithm.repository.OutboundStockSearch;
|
||||
import com.ruoyi.algorithm.repository.PendingOutboundJob;
|
||||
import com.ruoyi.algorithm.repository.RepositoryPolicy;
|
||||
import com.ruoyi.algorithm.repository.ReservationResult;
|
||||
import com.ruoyi.algorithm.repository.ReservationStatus;
|
||||
import com.ruoyi.algorithm.repository.SpaceSnapshot;
|
||||
import com.ruoyi.algorithm.repository.VersionToken;
|
||||
import com.ruoyi.algorithm.repository.WarehouseRepository;
|
||||
import com.ruoyi.algorithm.repository.WarehouseSummary;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class WarehouseApplicationService {
|
||||
private final WarehouseRepository repository;
|
||||
private final InboundPlanner inboundPlanner;
|
||||
private final OutboundPlanner outboundPlanner;
|
||||
private final TidyPlanner tidyPlanner;
|
||||
private final int pageSize;
|
||||
|
||||
public WarehouseApplicationService(WarehouseRepository repository, InboundPlanner inboundPlanner,
|
||||
OutboundPlanner outboundPlanner, TidyPlanner tidyPlanner, int pageSize) {
|
||||
this.repository = Objects.requireNonNull(repository, "repository");
|
||||
this.inboundPlanner = Objects.requireNonNull(inboundPlanner, "inboundPlanner");
|
||||
this.outboundPlanner = Objects.requireNonNull(outboundPlanner, "outboundPlanner");
|
||||
this.tidyPlanner = Objects.requireNonNull(tidyPlanner, "tidyPlanner");
|
||||
this.pageSize = Math.max(1, Math.min(200, pageSize));
|
||||
}
|
||||
|
||||
public WarehouseSummary summary(String warehouseId) {
|
||||
return repository.loadSummary(warehouseId);
|
||||
}
|
||||
|
||||
public WarehouseDiagnostics diagnostics(String warehouseId) {
|
||||
WarehouseSummary summary = repository.loadSummary(warehouseId);
|
||||
List<String> issues = new ArrayList<>();
|
||||
forEachLanePage(warehouseId, false, snapshots -> snapshots.stream()
|
||||
.flatMap(lane -> lane.spaces().stream())
|
||||
.filter(space -> space.diagnostic() != null)
|
||||
.forEach(space -> issues.add(space.spaceId() + ":" + space.diagnostic())));
|
||||
return new WarehouseDiagnostics(summary, issues.stream().sorted().toList());
|
||||
}
|
||||
|
||||
public InboundPlanningResult planInbound(String warehouseId, InboundRequest request) {
|
||||
repository.refreshChangedLanes(warehouseId);
|
||||
RepositoryPolicy policy = repository.loadPolicy(warehouseId);
|
||||
WarehouseSummary summary = repository.loadSummary(warehouseId);
|
||||
InboundRequest effectiveRequest = request;
|
||||
if (request.expectedProjectPallets() == null && nonBlank(request.projectNo())) {
|
||||
int currentProjectPallets = repository.countProjectPallets(warehouseId, request.projectNo());
|
||||
effectiveRequest = new InboundRequest(request.requestId(), request.trayNo(), request.projectNo(),
|
||||
request.productId(), request.lotNo(), currentProjectPallets <= 1 ? 2 : 4,
|
||||
request.entryFloor(), request.targetFloor(), request.allowedFloors(), request.batchLaneMode());
|
||||
}
|
||||
List<PendingOutbound> pending = repository.loadPendingOutboundJobs(warehouseId).stream()
|
||||
.map(job -> new PendingOutbound(job.projectNo(), job.productNo(), job.trayNo(),
|
||||
job.sourceLaneCode(), job.sourceResolution())).toList();
|
||||
InboundPlanner.PagedSession session = inboundPlanner.startPaged(effectiveRequest,
|
||||
policy.inboundPolicy(), pending, summary.occupiedCount());
|
||||
Map<String, VersionToken> versions = new LinkedHashMap<>();
|
||||
String cursor = null;
|
||||
do {
|
||||
LanePage page = repository.pageCandidateLanes(warehouseId, cursor, pageSize);
|
||||
List<String> laneCodes = page.lanes().stream().map(LaneCandidate::laneCode).toList();
|
||||
if (!laneCodes.isEmpty()) {
|
||||
List<LaneSnapshot> snapshots = repository.loadLaneSnapshots(warehouseId, laneCodes);
|
||||
snapshots.forEach(lane -> versions.put(lane.laneCode(), lane.version()));
|
||||
session.accept(snapshots.stream().flatMap(lane -> lane.spaceStates().stream()).toList());
|
||||
versions.keySet().retainAll(session.retainedLaneCodes());
|
||||
}
|
||||
cursor = page.nextLaneCode();
|
||||
} while (cursor != null);
|
||||
InboundPlan plan = session.finish();
|
||||
VersionToken version = plan.assignment().map(value -> versions.get(value.laneCode())).orElse(null);
|
||||
String resultCode = plan.assignment().isPresent() ? "READY"
|
||||
: "REJECTED_" + plan.rejection().orElseThrow().code().name();
|
||||
String reason = plan.assignment().map(value -> value.reason())
|
||||
.orElseGet(() -> plan.rejection().orElseThrow().reason());
|
||||
log(warehouseId, "INBOUND", version, resultCode, reason);
|
||||
return new InboundPlanningResult(plan, version);
|
||||
}
|
||||
|
||||
public OutboundPlan planOutbound(String warehouseId, OutboundRequest request) {
|
||||
repository.refreshChangedLanes(warehouseId);
|
||||
RepositoryPolicy policy = repository.loadPolicy(warehouseId);
|
||||
WarehouseSummary summary = repository.loadSummary(warehouseId);
|
||||
OutboundRequest effectiveRequest = request.fifoToleranceMinutes() == null && policy.fifoToleranceMinutes() > 0
|
||||
? new OutboundRequest(request.requestId(), request.trayNo(), request.projectNo(), request.productId(),
|
||||
request.fifoMode(), policy.fifoToleranceMinutes(), request.exitFloor(), request.wholeTray())
|
||||
: request;
|
||||
List<OutboundStockMatch> matches = new ArrayList<>();
|
||||
String matchedProductScope = null;
|
||||
if (nonBlank(request.trayNo())) {
|
||||
matches.addAll(repository.searchOutboundStock(warehouseId, OutboundStockSearch.exactTray(request.trayNo())));
|
||||
boolean fifoEnabled = nonBlank(request.fifoMode()) && !"OFF".equalsIgnoreCase(request.fifoMode());
|
||||
Set<String> spaces = matches.stream().map(OutboundStockMatch::spaceId).filter(this::nonBlank)
|
||||
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
||||
Set<String> projects = matches.stream().map(OutboundStockMatch::projectNo).filter(this::nonBlank)
|
||||
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
||||
Set<String> products = matches.stream().map(OutboundStockMatch::productNo).filter(this::nonBlank)
|
||||
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
||||
if (spaces.size() > 1 || projects.size() > 1) {
|
||||
OutboundPlan ambiguous = OutboundPlan.error(new OutboundError(OutboundErrorCode.AMBIGUOUS_TRAY,
|
||||
"Exact tray resolves to multiple spaces or inventory scopes.",
|
||||
Map.of("spaceCount", spaces.size(), "projectCount", projects.size(),
|
||||
"productCount", products.size())));
|
||||
log(warehouseId, "OUTBOUND", null, "ERROR_AMBIGUOUS_TRAY",
|
||||
ambiguous.error().orElseThrow().message());
|
||||
return ambiguous;
|
||||
}
|
||||
if (fifoEnabled && projects.size() == 1) {
|
||||
String project = projects.iterator().next();
|
||||
String product = products.size() == 1 ? products.iterator().next() : null;
|
||||
matchedProductScope = product;
|
||||
List<OutboundStockMatch> scoped = repository.searchOutboundStock(warehouseId,
|
||||
OutboundStockSearch.project(project, product));
|
||||
scoped.forEach(match -> {
|
||||
if (matches.stream().noneMatch(existing -> existing.stockId().equals(match.stockId()))) {
|
||||
matches.add(match);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (nonBlank(request.projectNo())) {
|
||||
matchedProductScope = nonBlank(request.productId()) ? request.productId() : null;
|
||||
matches.addAll(repository.searchOutboundStock(warehouseId,
|
||||
OutboundStockSearch.project(request.projectNo(), request.productId())));
|
||||
}
|
||||
List<String> laneCodes = matches.stream().map(OutboundStockMatch::laneCode).filter(Objects::nonNull)
|
||||
.distinct().sorted().toList();
|
||||
List<LaneSnapshot> lanes = repository.loadLaneSnapshots(warehouseId, laneCodes);
|
||||
Set<Integer> floors = matches.stream().map(OutboundStockMatch::floorNo)
|
||||
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
||||
List<SpaceSnapshot> buffers = repository.loadRelocationBuffers(warehouseId, floors);
|
||||
Map<String, SpaceState> uniqueSpaces = new LinkedHashMap<>();
|
||||
Set<String> matchedSpaceIds = matches.stream().map(OutboundStockMatch::spaceId)
|
||||
.filter(this::nonBlank).collect(java.util.stream.Collectors.toSet());
|
||||
String productScope = matchedProductScope;
|
||||
lanes.stream().flatMap(lane -> lane.spaceStates().stream()).forEach(space -> uniqueSpaces.put(space.spaceId(),
|
||||
productScope != null && matchedSpaceIds.contains(space.spaceId())
|
||||
? withProductScope(space, productScope) : space));
|
||||
buffers.stream().map(SpaceSnapshot::state).forEach(space -> uniqueSpaces.put(space.spaceId(), space));
|
||||
Map<String, String> rawInboundTimes = new LinkedHashMap<>();
|
||||
matches.stream().filter(match -> match.inboundTimeRaw() != null)
|
||||
.forEach(match -> rawInboundTimes.putIfAbsent(match.spaceId(), match.inboundTimeRaw()));
|
||||
lanes.stream().flatMap(lane -> lane.spaces().stream()).forEach(space -> space.inventory().stream()
|
||||
.filter(item -> item.inboundTimeRaw() != null)
|
||||
.forEach(item -> rawInboundTimes.putIfAbsent(space.spaceId(), item.inboundTimeRaw())));
|
||||
OutboundPlan plan = outboundPlanner.plan(effectiveRequest,
|
||||
new OutboundContext(List.copyOf(uniqueSpaces.values()), rawInboundTimes,
|
||||
capacityMode(summary, policy),
|
||||
repository.loadActiveDeepWorkflowFloors(warehouseId, request.requestId())));
|
||||
String resultCode = switch (plan.status()) {
|
||||
case READY -> "READY";
|
||||
case WAITING -> "WAITING_" + plan.waiting().orElseThrow().code().name();
|
||||
case ERROR -> "ERROR_" + plan.error().orElseThrow().code().name();
|
||||
};
|
||||
String reason = plan.assignment().map(value -> value.reason())
|
||||
.or(() -> plan.waiting().map(value -> value.reason()))
|
||||
.or(() -> plan.error().map(value -> value.message())).orElse(resultCode);
|
||||
log(warehouseId, "OUTBOUND", null, resultCode, reason);
|
||||
return plan;
|
||||
}
|
||||
|
||||
public TidyPlan planTidy(String warehouseId, Instant now) {
|
||||
RepositoryPolicy policy = repository.loadPolicy(warehouseId);
|
||||
List<SpaceState> spaces = new ArrayList<>();
|
||||
forEachLanePage(warehouseId, false,
|
||||
snapshots -> snapshots.forEach(lane -> spaces.addAll(lane.spaceStates())));
|
||||
List<TidyPendingOutbound> pending = repository.loadPendingOutboundJobs(warehouseId).stream()
|
||||
.map(job -> new TidyPendingOutbound(job.projectNo(), job.productNo(), job.trayNo(),
|
||||
job.sourceLaneCode())).toList();
|
||||
TidyPlan plan = tidyPlanner.plan(new TidyRequest(spaces, pending, now,
|
||||
policy.schedulingPolicy(), policy.inboundPolicy().commonProjectNos()));
|
||||
String result = plan.candidates().isEmpty() ? "EMPTY" : "READY";
|
||||
log(warehouseId, "TIDY", null, result,
|
||||
plan.diagnostics().isEmpty() ? "candidates=" + plan.candidates().size()
|
||||
: String.join("; ", plan.diagnostics()));
|
||||
return plan;
|
||||
}
|
||||
|
||||
public InboundReservationOutcome reserveInbound(String reservationId, String warehouseId, String projectNo,
|
||||
InboundPlanningResult planned) {
|
||||
var assignment = planned.plan().assignment().orElseThrow(
|
||||
() -> new IllegalArgumentException("Only an accepted inbound plan can be reserved"));
|
||||
ReservationResult result = repository.tryReserveInbound(new InboundReservation(reservationId, warehouseId,
|
||||
assignment.laneCode(), assignment.spaceId(), projectNo, planned.laneVersion()));
|
||||
String resultCode = result.status() == ReservationStatus.CONFLICT ? "REPLAN_REQUIRED" : result.status().name();
|
||||
log(warehouseId, "INBOUND_RESERVATION", planned.laneVersion(), resultCode,
|
||||
result.reasonCode() == null ? resultCode : result.reasonCode());
|
||||
return new InboundReservationOutcome(resultCode, result);
|
||||
}
|
||||
|
||||
public InboundReservationOutcome reserveInbound(InboundReservation reservation) {
|
||||
ReservationResult result = repository.tryReserveInbound(reservation);
|
||||
String resultCode = result.status() == ReservationStatus.CONFLICT ? "REPLAN_REQUIRED" : result.status().name();
|
||||
log(reservation.warehouseId(), "INBOUND_RESERVATION", reservation.expectedVersion(), resultCode,
|
||||
result.reasonCode() == null ? resultCode : result.reasonCode());
|
||||
return new InboundReservationOutcome(resultCode, result);
|
||||
}
|
||||
|
||||
public ReservationResult releaseReservation(String warehouseId, String reservationId) {
|
||||
ReservationResult result = repository.releaseReservation(warehouseId, reservationId);
|
||||
log(warehouseId, "RESERVATION_RELEASE", null, result.status().name(), result.reasonCode());
|
||||
return result;
|
||||
}
|
||||
|
||||
public DeepWorkflowMutationResult acquireDeepWorkflowLock(String warehouseId, String requestId, int floorNo) {
|
||||
DeepWorkflowMutationResult result = repository.acquireDeepWorkflowLockResult(requestId, warehouseId, floorNo);
|
||||
log(warehouseId, "DEEP_WORKFLOW_LOCK", null, result.changed() ? "ACQUIRED"
|
||||
: result.reasonCode() == null ? "CONFLICT" : result.reasonCode(),
|
||||
"request=" + requestId + ", floor=" + floorNo);
|
||||
return result;
|
||||
}
|
||||
|
||||
public DeepWorkflowMutationResult releaseDeepWorkflowLock(String warehouseId, String requestId, int floorNo) {
|
||||
DeepWorkflowMutationResult result = repository.releaseDeepWorkflowLockResult(requestId, warehouseId, floorNo);
|
||||
log(warehouseId, "DEEP_WORKFLOW_RELEASE", null, result.changed() ? "RELEASED"
|
||||
: result.reasonCode() == null ? "NOT_FOUND" : result.reasonCode(),
|
||||
"request=" + requestId + ", floor=" + floorNo);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void forEachLanePage(String warehouseId, boolean candidatesOnly,
|
||||
java.util.function.Consumer<List<LaneSnapshot>> consumer) {
|
||||
String cursor = null;
|
||||
do {
|
||||
LanePage page = candidatesOnly
|
||||
? repository.pageCandidateLanes(warehouseId, cursor, pageSize)
|
||||
: repository.pageWarehouseLanes(warehouseId, cursor, pageSize);
|
||||
List<String> laneCodes = page.lanes().stream().map(LaneCandidate::laneCode).toList();
|
||||
if (!laneCodes.isEmpty()) consumer.accept(repository.loadLaneSnapshots(warehouseId, laneCodes));
|
||||
cursor = page.nextLaneCode();
|
||||
} while (cursor != null);
|
||||
}
|
||||
|
||||
private void log(String warehouseId, String type, VersionToken version, String resultCode, String reason) {
|
||||
repository.insertDecision(new DecisionLogRecord(UUID.randomUUID().toString(), warehouseId, type,
|
||||
version, resultCode, reason));
|
||||
}
|
||||
|
||||
private OutboundCapacityMode capacityMode(WarehouseSummary summary, RepositoryPolicy policy) {
|
||||
int occupied = summary.occupiedCount();
|
||||
if (occupied >= policy.schedulingPolicy().capacityFirstOccupied()) {
|
||||
return OutboundCapacityMode.CAPACITY_FIRST;
|
||||
}
|
||||
if (occupied >= policy.schedulingPolicy().warningOccupied()) {
|
||||
return OutboundCapacityMode.WARNING;
|
||||
}
|
||||
return OutboundCapacityMode.BALANCED;
|
||||
}
|
||||
|
||||
private SpaceState withProductScope(SpaceState space, String productId) {
|
||||
return new SpaceState(space.spaceId(), space.zoneCode(), space.laneCode(), space.depthOrder(),
|
||||
space.laneCapacity(), space.floorNo(), space.aisleNo(), space.groupCoding(), space.rowCode(),
|
||||
space.role(), space.status(), space.outboundTaskActive(), space.inventoryProjectConflict(),
|
||||
space.trayNo(), space.projectNo(), productId, space.lotNo(), space.inboundAt(), space.outboundAt(),
|
||||
space.reserved(), space.rowVersion(), space.physicalStockPresent());
|
||||
}
|
||||
|
||||
private boolean nonBlank(String value) { return value != null && !value.isBlank(); }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ruoyi.algorithm.service;
|
||||
|
||||
import com.ruoyi.algorithm.repository.WarehouseSummary;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record WarehouseDiagnostics(WarehouseSummary summary, List<String> issues) {
|
||||
public WarehouseDiagnostics { issues = List.copyOf(issues); }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ruoyi.algorithm.tidy;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public record TidyCandidate(
|
||||
TidyCandidateType type,
|
||||
String projectNo,
|
||||
List<TidyMove> moves,
|
||||
Optional<String> releasesLaneCode,
|
||||
String reason
|
||||
) {
|
||||
public TidyCandidate {
|
||||
moves = List.copyOf(moves);
|
||||
releasesLaneCode = releasesLaneCode == null ? Optional.empty() : releasesLaneCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.tidy;
|
||||
|
||||
public enum TidyCandidateType {
|
||||
GUEST_RETURN,
|
||||
DOWNGRADE_MERGE,
|
||||
CONSOLIDATE_INCUBATION
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ruoyi.algorithm.tidy;
|
||||
|
||||
public record TidyMove(String trayNo, String fromSpaceId, String toSpaceId) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ruoyi.algorithm.tidy;
|
||||
|
||||
public record TidyPendingOutbound(String projectNo, String productId, String trayNo, String laneCode) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ruoyi.algorithm.tidy;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TidyPlan(List<TidyCandidate> candidates, List<String> diagnostics) {
|
||||
public TidyPlan {
|
||||
candidates = List.copyOf(candidates);
|
||||
diagnostics = List.copyOf(diagnostics);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package com.ruoyi.algorithm.tidy;
|
||||
|
||||
import com.ruoyi.algorithm.model.SpaceRole;
|
||||
import com.ruoyi.algorithm.model.SpaceState;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
public final class TidyPlanner {
|
||||
private static final Duration STAGNANT_AGE = Duration.ofHours(72);
|
||||
|
||||
public TidyPlan plan(TidyRequest request) {
|
||||
Map<String, LaneView> lanes = laneViews(request.spaces());
|
||||
Set<String> pendingLanes = pendingLaneCodes(request);
|
||||
Set<String> usedSources = new HashSet<>();
|
||||
Set<String> usedTargets = new HashSet<>();
|
||||
List<TidyCandidate> candidates = new ArrayList<>();
|
||||
List<String> diagnostics = new ArrayList<>();
|
||||
Map<String, Instant> newestInboundByProject = newestInboundByProject(request.spaces());
|
||||
|
||||
addGuestReturns(lanes, pendingLanes, usedSources, usedTargets, candidates);
|
||||
if (request.now() == null) {
|
||||
diagnostics.add("No current time supplied; DOWNGRADE_MERGE candidates were skipped");
|
||||
} else {
|
||||
addDowngrades(lanes, pendingLanes, request.now(), newestInboundByProject,
|
||||
request.commonProjectNos(), usedSources, usedTargets, candidates);
|
||||
}
|
||||
addConsolidations(lanes, pendingLanes, request.commonProjectNos(),
|
||||
usedSources, usedTargets, candidates);
|
||||
|
||||
candidates.sort(Comparator.comparingInt((TidyCandidate candidate) -> candidate.type().ordinal())
|
||||
.thenComparingInt(candidate -> candidate.releasesLaneCode().isPresent() ? 0 : 1)
|
||||
.thenComparingInt(candidate -> candidate.moves().size())
|
||||
.thenComparing(TidyCandidate::projectNo, Comparator.nullsLast(String::compareTo)));
|
||||
int maximum = Math.max(0, request.policy().maxTidyCandidates());
|
||||
if (candidates.size() > maximum) {
|
||||
diagnostics.add("Tidy candidates exceeded policy maximum; retained first " + maximum);
|
||||
candidates = new ArrayList<>(candidates.subList(0, maximum));
|
||||
}
|
||||
return new TidyPlan(candidates, diagnostics);
|
||||
}
|
||||
|
||||
private void addGuestReturns(Map<String, LaneView> lanes, Set<String> pendingLanes,
|
||||
Set<String> usedSources, Set<String> usedTargets,
|
||||
List<TidyCandidate> candidates) {
|
||||
lanes.values().stream().sorted(Comparator.comparing(LaneView::laneCode)).forEach(source -> {
|
||||
if (source.corrupt() || source.guestProject() == null || pendingLanes.contains(source.laneCode())
|
||||
|| laneTouched(source, usedSources, usedTargets)) return;
|
||||
List<SpaceState> guests = source.guestSpaces().stream()
|
||||
.sorted(Comparator.comparingInt(SpaceState::groupCoding).reversed()
|
||||
.thenComparing(SpaceState::spaceId))
|
||||
.toList();
|
||||
if (guests.stream().anyMatch(space -> usedSources.contains(space.spaceId()))) return;
|
||||
LaneView target = lanes.values().stream()
|
||||
.filter(lane -> !lane.laneCode().equals(source.laneCode()))
|
||||
.filter(lane -> !lane.corrupt() && lane.guestProject() == null)
|
||||
.filter(lane -> source.guestProject().equals(lane.residentProject()))
|
||||
.filter(lane -> !pendingLanes.contains(lane.laneCode()))
|
||||
.filter(lane -> !laneTouched(lane, usedSources, usedTargets))
|
||||
.filter(lane -> availableTargets(lane, usedTargets).size() >= guests.size())
|
||||
.min(Comparator.comparingInt((LaneView lane) -> Math.abs(lane.floorNo() - source.floorNo()))
|
||||
.thenComparing(Comparator.comparingInt((LaneView lane) -> lane.occupied().size()).reversed())
|
||||
.thenComparing(LaneView::laneCode))
|
||||
.orElse(null);
|
||||
if (target == null) {
|
||||
target = fallbackGuestDestination(source, source.guestProject(), guests.size(), lanes,
|
||||
pendingLanes, usedSources, usedTargets);
|
||||
}
|
||||
if (target == null) return;
|
||||
List<SpaceState> targetSpaces = availableTargets(target, usedTargets).subList(0, guests.size());
|
||||
List<TidyMove> moves = moves(guests, targetSpaces);
|
||||
reserve(moves, usedSources, usedTargets);
|
||||
candidates.add(new TidyCandidate(TidyCandidateType.GUEST_RETURN, source.guestProject(), moves,
|
||||
Optional.empty(), "Return guest project stock to its own lane"));
|
||||
});
|
||||
}
|
||||
|
||||
private void addDowngrades(Map<String, LaneView> lanes, Set<String> pendingLanes, Instant now,
|
||||
Map<String, Instant> newestInboundByProject, Set<String> commonProjectNos,
|
||||
Set<String> usedSources, Set<String> usedTargets,
|
||||
List<TidyCandidate> candidates) {
|
||||
Instant threshold = now.minus(STAGNANT_AGE);
|
||||
lanes.values().stream().sorted(Comparator.comparing(LaneView::laneCode)).forEach(source -> {
|
||||
if (source.capacity() != 5 || source.corrupt() || source.guestProject() != null
|
||||
|| source.residentProject() == null || source.occupied().isEmpty()
|
||||
|| source.occupied().size() > 2 || pendingLanes.contains(source.laneCode())
|
||||
|| commonProjectNos.contains(source.residentProject())
|
||||
|| laneTouched(source, usedSources, usedTargets)) return;
|
||||
Instant projectNewest = newestInboundByProject.get(source.residentProject());
|
||||
if (projectNewest == null || projectNewest.isAfter(threshold)) return;
|
||||
LaneView target = source.occupied().size() == 1 ? lanes.values().stream()
|
||||
.filter(lane -> lane.capacity() == 1 && lane.occupied().isEmpty() && !lane.corrupt())
|
||||
.filter(lane -> singlePoolTier(lane) < 2)
|
||||
.filter(lane -> !pendingLanes.contains(lane.laneCode()))
|
||||
.filter(lane -> !laneTouched(lane, usedSources, usedTargets))
|
||||
.filter(lane -> availableTargets(lane, usedTargets).size() == 1)
|
||||
.min(Comparator.comparingInt(this::singlePoolTier)
|
||||
.thenComparingInt(lane -> Math.abs(lane.floorNo() - source.floorNo()))
|
||||
.thenComparing(LaneView::laneCode))
|
||||
.orElse(null) : null;
|
||||
if (target == null) target = lanes.values().stream()
|
||||
.filter(lane -> lane.capacity() == 2 && lane.occupied().isEmpty() && !lane.corrupt())
|
||||
.filter(lane -> !pendingLanes.contains(lane.laneCode()))
|
||||
.filter(lane -> !laneTouched(lane, usedSources, usedTargets))
|
||||
.filter(lane -> availableTargets(lane, usedTargets).size() >= source.occupied().size())
|
||||
.min(Comparator.comparingInt((LaneView lane) -> Math.abs(lane.floorNo() - source.floorNo()))
|
||||
.thenComparing(LaneView::laneCode))
|
||||
.orElse(null);
|
||||
if (target == null) return;
|
||||
List<SpaceState> ordered = source.occupied().stream()
|
||||
.sorted(Comparator.comparingInt(SpaceState::groupCoding).reversed()
|
||||
.thenComparing(SpaceState::spaceId))
|
||||
.toList();
|
||||
List<TidyMove> moves = moves(ordered,
|
||||
availableTargets(target, usedTargets).subList(0, ordered.size()));
|
||||
reserve(moves, usedSources, usedTargets);
|
||||
candidates.add(new TidyCandidate(TidyCandidateType.DOWNGRADE_MERGE, source.residentProject(), moves,
|
||||
Optional.of(source.laneCode()), "Move stagnant stock to a shallower lane and release the deep lane"));
|
||||
});
|
||||
}
|
||||
|
||||
private void addConsolidations(Map<String, LaneView> lanes, Set<String> pendingLanes,
|
||||
Set<String> commonProjectNos,
|
||||
Set<String> usedSources, Set<String> usedTargets,
|
||||
List<TidyCandidate> candidates) {
|
||||
Set<String> projects = new HashSet<>();
|
||||
lanes.values().forEach(lane -> {
|
||||
if (lane.capacity() == 5 && !lane.corrupt() && lane.guestProject() == null
|
||||
&& lane.residentProject() != null && !lane.occupied().isEmpty()
|
||||
&& lane.occupied().stream().noneMatch(space -> usedSources.contains(space.spaceId()))
|
||||
&& lane.spaces().stream().noneMatch(space -> usedTargets.contains(space.spaceId()))
|
||||
&& !commonProjectNos.contains(lane.residentProject())
|
||||
&& !availableTargets(lane, usedTargets).isEmpty() && !pendingLanes.contains(lane.laneCode())) {
|
||||
projects.add(lane.residentProject());
|
||||
}
|
||||
});
|
||||
projects.stream().sorted().forEach(project -> {
|
||||
if (commonProjectNos.contains(project)) return;
|
||||
List<LaneView> deep = lanes.values().stream()
|
||||
.filter(lane -> lane.capacity() == 5 && !lane.corrupt() && lane.guestProject() == null)
|
||||
.filter(lane -> project.equals(lane.residentProject()))
|
||||
.filter(lane -> lane.occupied().stream().noneMatch(space -> usedSources.contains(space.spaceId())))
|
||||
.filter(lane -> lane.spaces().stream().noneMatch(space -> usedTargets.contains(space.spaceId())))
|
||||
.filter(lane -> !pendingLanes.contains(lane.laneCode()))
|
||||
.sorted(Comparator.comparingInt((LaneView lane) -> lane.occupied().size()).reversed()
|
||||
.thenComparing(LaneView::laneCode))
|
||||
.toList();
|
||||
if (deep.isEmpty()) return;
|
||||
List<SpaceState> shallow = lanes.values().stream()
|
||||
.filter(lane -> lane.capacity() <= 2 && !pendingLanes.contains(lane.laneCode()))
|
||||
.filter(lane -> !laneTouched(lane, usedSources, usedTargets))
|
||||
.flatMap(lane -> accessibleInPhysicalOrder(lane).stream())
|
||||
.filter(space -> project.equals(space.projectNo()))
|
||||
.filter(space -> !usedSources.contains(space.spaceId()))
|
||||
.sorted(Comparator.comparing(SpaceState::laneCode)
|
||||
.thenComparing(Comparator.comparingInt(SpaceState::groupCoding).reversed())
|
||||
.thenComparing(SpaceState::spaceId))
|
||||
.toList();
|
||||
if (shallow.isEmpty()) return;
|
||||
List<TidyMove> moves = new ArrayList<>();
|
||||
for (SpaceState source : shallow) {
|
||||
SpaceState target = deep.stream().flatMap(lane -> availableTargets(lane, usedTargets).stream())
|
||||
.findFirst().orElse(null);
|
||||
if (target == null) break;
|
||||
TidyMove move = new TidyMove(source.trayNo(), source.spaceId(), target.spaceId());
|
||||
moves.add(move);
|
||||
reserve(List.of(move), usedSources, usedTargets);
|
||||
}
|
||||
if (moves.isEmpty()) return;
|
||||
Optional<String> released = lanes.values().stream()
|
||||
.filter(lane -> lane.capacity() <= 2)
|
||||
.filter(lane -> lane.occupied().stream().allMatch(space -> usedSources.contains(space.spaceId())))
|
||||
.filter(lane -> lane.occupied().stream().anyMatch(space -> project.equals(space.projectNo())))
|
||||
.map(LaneView::laneCode).sorted().findFirst();
|
||||
candidates.add(new TidyCandidate(TidyCandidateType.CONSOLIDATE_INCUBATION, project, moves, released,
|
||||
"Consolidate accessible shallow stock into a current deep project lane"));
|
||||
});
|
||||
}
|
||||
|
||||
private LaneView fallbackGuestDestination(LaneView source, String projectNo, int requiredSlots,
|
||||
Map<String, LaneView> lanes, Set<String> pendingLanes,
|
||||
Set<String> usedSources, Set<String> usedTargets) {
|
||||
int projectPallets = (int) lanes.values().stream()
|
||||
.flatMap(lane -> lane.occupied().stream())
|
||||
.filter(space -> projectNo.equals(space.projectNo()))
|
||||
.count();
|
||||
int[] depthOrder = projectPallets >= 2 ? new int[]{5, 3, 2} : new int[]{2, 3};
|
||||
for (int depth : depthOrder) {
|
||||
LaneView empty = lanes.values().stream()
|
||||
.filter(lane -> lane.capacity() == depth && lane.occupied().isEmpty() && !lane.corrupt())
|
||||
.filter(lane -> !pendingLanes.contains(lane.laneCode()))
|
||||
.filter(lane -> !laneTouched(lane, usedSources, usedTargets))
|
||||
.filter(lane -> lane.spaces().stream().allMatch(this::ordinaryAvailableStorage))
|
||||
.filter(lane -> availableTargets(lane, usedTargets).size() >= requiredSlots)
|
||||
.min(Comparator.comparingInt((LaneView lane) -> Math.abs(lane.floorNo() - source.floorNo()))
|
||||
.thenComparing(LaneView::laneCode))
|
||||
.orElse(null);
|
||||
if (empty != null) return empty;
|
||||
}
|
||||
if (requiredSlots != 1) return null;
|
||||
return lanes.values().stream()
|
||||
.filter(lane -> lane.capacity() == 1 && lane.occupied().isEmpty() && !lane.corrupt())
|
||||
.filter(lane -> !pendingLanes.contains(lane.laneCode()))
|
||||
.filter(lane -> !laneTouched(lane, usedSources, usedTargets))
|
||||
.filter(lane -> lane.spaces().stream().allMatch(this::ordinaryAvailableStorage))
|
||||
.min(Comparator.comparingInt(this::singlePoolTier)
|
||||
.thenComparingInt(lane -> Math.abs(lane.floorNo() - source.floorNo()))
|
||||
.thenComparing(LaneView::laneCode))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private boolean ordinaryAvailableStorage(SpaceState space) {
|
||||
return space.role() == SpaceRole.STORAGE && space.availableEmpty();
|
||||
}
|
||||
|
||||
private int singlePoolTier(LaneView lane) {
|
||||
String zone = lane.spaces().get(0).zoneCode();
|
||||
if ("FLEX".equals(zone)) return 0;
|
||||
if ("CACHE".equals(zone)) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
private boolean laneTouched(LaneView lane, Set<String> usedSources, Set<String> usedTargets) {
|
||||
return lane.spaces().stream().anyMatch(space -> usedSources.contains(space.spaceId())
|
||||
|| usedTargets.contains(space.spaceId()));
|
||||
}
|
||||
|
||||
private Map<String, Instant> newestInboundByProject(List<SpaceState> spaces) {
|
||||
Map<String, Instant> result = new HashMap<>();
|
||||
spaces.stream().filter(SpaceState::occupied)
|
||||
.filter(space -> !blank(space.projectNo()) && space.inboundAt() != null)
|
||||
.forEach(space -> result.merge(space.projectNo(), space.inboundAt(),
|
||||
(left, right) -> left.isAfter(right) ? left : right));
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<SpaceState> accessibleInPhysicalOrder(LaneView lane) {
|
||||
if (lane.corrupt() || lane.occupied().isEmpty()) return List.of();
|
||||
return lane.occupied().stream()
|
||||
.sorted(Comparator.comparingInt(SpaceState::groupCoding).reversed()
|
||||
.thenComparing(SpaceState::spaceId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<TidyMove> moves(List<SpaceState> sources, List<SpaceState> targets) {
|
||||
List<TidyMove> result = new ArrayList<>();
|
||||
for (int index = 0; index < sources.size(); index++) {
|
||||
result.add(new TidyMove(sources.get(index).trayNo(), sources.get(index).spaceId(), targets.get(index).spaceId()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void reserve(List<TidyMove> moves, Set<String> usedSources, Set<String> usedTargets) {
|
||||
moves.forEach(move -> {
|
||||
usedSources.add(move.fromSpaceId());
|
||||
usedTargets.add(move.toSpaceId());
|
||||
});
|
||||
}
|
||||
|
||||
private List<SpaceState> availableTargets(LaneView lane, Set<String> usedTargets) {
|
||||
if (lane.corrupt()) return List.of();
|
||||
return lane.spaces().stream()
|
||||
.filter(space -> space.groupCoding() > lane.occupied().size())
|
||||
.filter(space -> space.role() == SpaceRole.STORAGE && space.availableEmpty())
|
||||
.filter(space -> !usedTargets.contains(space.spaceId()))
|
||||
.sorted(Comparator.comparingInt(SpaceState::groupCoding).thenComparing(SpaceState::spaceId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Set<String> pendingLaneCodes(TidyRequest request) {
|
||||
Set<String> result = new HashSet<>();
|
||||
request.spaces().stream().filter(SpaceState::outboundTaskActive)
|
||||
.map(SpaceState::laneCode).forEach(result::add);
|
||||
for (TidyPendingOutbound pending : request.pendingOutbounds()) {
|
||||
if (!blank(pending.laneCode())) result.add(pending.laneCode());
|
||||
request.spaces().stream().filter(SpaceState::occupied).filter(space -> pendingMatches(space, pending))
|
||||
.map(SpaceState::laneCode).forEach(result::add);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean pendingMatches(SpaceState space, TidyPendingOutbound pending) {
|
||||
if (!blank(pending.trayNo())) return pending.trayNo().equals(space.trayNo());
|
||||
if (blank(pending.projectNo())) return false;
|
||||
return pending.projectNo().equals(space.projectNo())
|
||||
&& (blank(pending.productId()) || pending.productId().equals(space.productId()));
|
||||
}
|
||||
|
||||
private Map<String, LaneView> laneViews(List<SpaceState> spaces) {
|
||||
Map<String, List<SpaceState>> grouped = new LinkedHashMap<>();
|
||||
spaces.forEach(space -> grouped.computeIfAbsent(space.laneCode(), ignored -> new ArrayList<>()).add(space));
|
||||
Map<String, LaneView> result = new HashMap<>();
|
||||
grouped.forEach((laneCode, members) -> result.put(laneCode, analyze(laneCode, members)));
|
||||
return result;
|
||||
}
|
||||
|
||||
private LaneView analyze(String laneCode, List<SpaceState> members) {
|
||||
List<SpaceState> ordered = members.stream()
|
||||
.sorted(Comparator.comparingInt(SpaceState::groupCoding).thenComparing(SpaceState::spaceId))
|
||||
.toList();
|
||||
int capacity = ordered.stream().mapToInt(SpaceState::laneCapacity).max().orElse(0);
|
||||
List<SpaceState> occupied = ordered.stream().filter(SpaceState::occupied).toList();
|
||||
boolean corrupt = ordered.size() != capacity;
|
||||
boolean emptySeen = false;
|
||||
List<String> segments = new ArrayList<>();
|
||||
String last = null;
|
||||
for (SpaceState space : ordered) {
|
||||
if (!space.occupied()) {
|
||||
emptySeen = true;
|
||||
continue;
|
||||
}
|
||||
if (emptySeen || space.inventoryProjectConflict() || !space.outboundSelectable()) corrupt = true;
|
||||
String projectNo = space.projectNo();
|
||||
if (blank(projectNo)) {
|
||||
corrupt = true;
|
||||
continue;
|
||||
}
|
||||
if (!projectNo.equals(last)) {
|
||||
segments.add(projectNo);
|
||||
last = projectNo;
|
||||
}
|
||||
}
|
||||
if (segments.size() > 2) corrupt = true;
|
||||
String resident = segments.isEmpty() ? null : segments.get(0);
|
||||
String guest = segments.size() < 2 ? null : segments.get(1);
|
||||
String finalGuest = guest;
|
||||
List<SpaceState> guests = guest == null ? List.of()
|
||||
: occupied.stream().filter(space -> finalGuest.equals(space.projectNo())).toList();
|
||||
int floor = ordered.isEmpty() ? 0 : ordered.get(0).floorNo();
|
||||
return new LaneView(laneCode, capacity, floor, ordered, occupied, corrupt, resident, guest, guests);
|
||||
}
|
||||
|
||||
private boolean blank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private record LaneView(
|
||||
String laneCode,
|
||||
int capacity,
|
||||
int floorNo,
|
||||
List<SpaceState> spaces,
|
||||
List<SpaceState> occupied,
|
||||
boolean corrupt,
|
||||
String residentProject,
|
||||
String guestProject,
|
||||
List<SpaceState> guestSpaces
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ruoyi.algorithm.tidy;
|
||||
|
||||
import com.ruoyi.algorithm.model.SpaceState;
|
||||
import com.ruoyi.algorithm.policy.SchedulingPolicy;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public record TidyRequest(
|
||||
List<SpaceState> spaces,
|
||||
List<TidyPendingOutbound> pendingOutbounds,
|
||||
Instant now,
|
||||
SchedulingPolicy policy,
|
||||
Set<String> commonProjectNos
|
||||
) {
|
||||
public TidyRequest {
|
||||
spaces = List.copyOf(spaces);
|
||||
pendingOutbounds = pendingOutbounds == null ? List.of() : List.copyOf(pendingOutbounds);
|
||||
policy = policy == null ? SchedulingPolicy.ft546Defaults() : policy;
|
||||
commonProjectNos = commonProjectNos == null ? Set.of("COMMON") : Set.copyOf(commonProjectNos);
|
||||
}
|
||||
|
||||
public TidyRequest(List<SpaceState> spaces, List<TidyPendingOutbound> pendingOutbounds,
|
||||
Instant now, SchedulingPolicy policy) {
|
||||
this(spaces, pendingOutbounds, now, policy, Set.of("COMMON"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/* Read-only validation queries for a SQL Server 2016 installation. */
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT CAST(SERVERPROPERTY('ProductVersion') AS varchar(30)) AS product_version,
|
||||
DB_NAME() AS database_name;
|
||||
|
||||
SELECT o.type_desc, COUNT(*) AS object_count
|
||||
FROM sys.objects o
|
||||
JOIN sys.schemas s ON s.schema_id = o.schema_id
|
||||
WHERE s.name = N'alg' AND o.type IN ('U', 'V', 'P')
|
||||
GROUP BY o.type_desc
|
||||
ORDER BY o.type_desc;
|
||||
|
||||
SELECT installed.object_kind, installed.object_name
|
||||
FROM (
|
||||
SELECT o.type_desc AS object_kind, o.name AS object_name
|
||||
FROM sys.objects o
|
||||
JOIN sys.schemas s ON s.schema_id = o.schema_id
|
||||
WHERE s.name = N'alg' AND o.type IN ('U', 'V', 'P')
|
||||
UNION ALL
|
||||
SELECT 'INDEX' AS object_kind, i.name AS object_name
|
||||
FROM sys.indexes i
|
||||
JOIN sys.objects o ON o.object_id = i.object_id
|
||||
JOIN sys.schemas s ON s.schema_id = o.schema_id
|
||||
WHERE s.name = N'alg' AND i.name IS NOT NULL
|
||||
) installed
|
||||
ORDER BY installed.object_kind, installed.object_name;
|
||||
|
||||
SELECT warehouse_id,
|
||||
COUNT_BIG(*) AS excluded_from_algorithm_topology_count,
|
||||
SUM(CASE WHEN group_coding_no IS NULL OR LTRIM(RTRIM(group_coding_no)) = ''
|
||||
THEN 1 ELSE 0 END) AS missing_lane_code_count,
|
||||
SUM(CASE WHEN TRY_CONVERT(int, group_coding) IS NULL
|
||||
THEN 1 ELSE 0 END) AS invalid_depth_count
|
||||
FROM dbo.bas_cargo_space
|
||||
WHERE group_coding_no IS NULL OR LTRIM(RTRIM(group_coding_no)) = ''
|
||||
OR TRY_CONVERT(int, group_coding) IS NULL
|
||||
GROUP BY warehouse_id
|
||||
ORDER BY warehouse_id;
|
||||
|
||||
SELECT warehouse_id,
|
||||
COUNT(*) AS space_state_count,
|
||||
SUM(CASE WHEN space_status = 2 THEN 1 ELSE 0 END) AS full_space_count,
|
||||
SUM(CASE WHEN inbound_available = 1 THEN 1 ELSE 0 END) AS inbound_available_count,
|
||||
SUM(CASE WHEN pending_outbound = 1 THEN 1 ELSE 0 END) AS pending_outbound_space_count,
|
||||
SUM(CASE WHEN has_gap = 1 THEN 1 ELSE 0 END) AS gap_space_count,
|
||||
SUM(CASE WHEN tray_conflict = 1 OR project_conflict = 1 OR product_conflict = 1 THEN 1 ELSE 0 END) AS conflict_space_count
|
||||
FROM alg.v_space_state
|
||||
GROUP BY warehouse_id
|
||||
ORDER BY warehouse_id;
|
||||
|
||||
SELECT warehouse_id, COUNT(*) AS topology_count
|
||||
FROM alg.space_topology
|
||||
GROUP BY warehouse_id
|
||||
ORDER BY warehouse_id;
|
||||
|
||||
SELECT warehouse_id,
|
||||
COUNT(*) AS inventory_detail_snapshot_count,
|
||||
COUNT(DISTINCT space_id) AS inventory_space_snapshot_count,
|
||||
MAX(synchronized_at) AS last_inventory_synchronized_at
|
||||
FROM alg.inventory_item_snapshot
|
||||
GROUP BY warehouse_id
|
||||
ORDER BY warehouse_id;
|
||||
|
||||
;WITH warehouse_floors AS (
|
||||
SELECT DISTINCT warehouse_id, the_floor AS floor_no
|
||||
FROM dbo.bas_cargo_space
|
||||
), role_counts AS (
|
||||
SELECT cargo.warehouse_id, cargo.the_floor AS floor_no,
|
||||
SUM(CASE WHEN role.role = 'RELOCATION_BUFFER' THEN 1 ELSE 0 END) AS relocation_buffer_count,
|
||||
SUM(CASE WHEN role.role = 'OUTBOUND_BUFFER' THEN 1 ELSE 0 END) AS outbound_buffer_count,
|
||||
SUM(CASE WHEN role.role = 'PROTECTED_CORE' THEN 1 ELSE 0 END) AS protected_core_count
|
||||
FROM dbo.bas_cargo_space cargo
|
||||
LEFT JOIN alg.space_role role ON role.space_id = cargo.cargo_space_id
|
||||
GROUP BY cargo.warehouse_id, cargo.the_floor
|
||||
)
|
||||
SELECT floors.warehouse_id, floors.floor_no,
|
||||
ISNULL(counts.relocation_buffer_count, 0) AS relocation_buffer_count,
|
||||
CAST(4 AS int) AS relocation_buffer_target,
|
||||
CASE WHEN ISNULL(counts.relocation_buffer_count, 0) >= 4 THEN 0
|
||||
ELSE 4 - ISNULL(counts.relocation_buffer_count, 0) END AS relocation_buffer_shortage,
|
||||
ISNULL(counts.outbound_buffer_count, 0) AS outbound_buffer_count,
|
||||
ISNULL(counts.protected_core_count, 0) AS protected_core_count
|
||||
FROM warehouse_floors floors
|
||||
LEFT JOIN role_counts counts ON counts.warehouse_id = floors.warehouse_id
|
||||
AND counts.floor_no = floors.floor_no
|
||||
ORDER BY floors.warehouse_id, floors.floor_no;
|
||||
|
||||
SELECT cargo.warehouse_id, cargo.the_floor AS floor_no, role.space_id, role.role, role.updated_at
|
||||
FROM alg.space_role role
|
||||
JOIN dbo.bas_cargo_space cargo ON cargo.cargo_space_id = role.space_id
|
||||
WHERE role.role IN ('RELOCATION_BUFFER', 'OUTBOUND_BUFFER', 'PROTECTED_CORE')
|
||||
ORDER BY cargo.warehouse_id, cargo.the_floor, role.role, role.space_id;
|
||||
|
||||
SELECT warehouse_id, COUNT(*) AS lane_state_count,
|
||||
SUM(CASE WHEN pending_outbound = 1 THEN 1 ELSE 0 END) AS pending_lane_count,
|
||||
SUM(CASE WHEN has_gap = 1 THEN 1 ELSE 0 END) AS gap_lane_count
|
||||
FROM alg.lane_state
|
||||
GROUP BY warehouse_id
|
||||
ORDER BY warehouse_id;
|
||||
|
||||
SELECT 'bas_cargo_space' AS table_name, COUNT_BIG(*) AS row_count,
|
||||
CHECKSUM_AGG(BINARY_CHECKSUM(cargo_space_id, warehouse_id, group_coding_no, group_coding, status, tray_code)) AS row_checksum
|
||||
FROM dbo.bas_cargo_space
|
||||
UNION ALL
|
||||
SELECT 'bas_stock', COUNT_BIG(*),
|
||||
CHECKSUM_AGG(BINARY_CHECKSUM(stock_id, cargo_space_id, tray_code, spare4, product_id, lot_no, in_date))
|
||||
FROM dbo.bas_stock
|
||||
UNION ALL
|
||||
SELECT 'mydo_job', COUNT_BIG(*),
|
||||
CHECKSUM_AGG(BINARY_CHECKSUM(job_id, status, task_no, tray_code, cargo_space_id, target_cargo_space_id, in_or_out, warehouse_id))
|
||||
FROM dbo.mydo_job;
|
||||
|
||||
;WITH pallet_details AS (
|
||||
SELECT cargo_space_id,
|
||||
MIN(NULLIF(LTRIM(RTRIM(tray_code)), '')) AS tray_code,
|
||||
MIN(NULLIF(LTRIM(RTRIM(spare4)), '')) AS project_no,
|
||||
MIN(NULLIF(LTRIM(RTRIM(product_id)), '')) AS first_product_no,
|
||||
MAX(NULLIF(LTRIM(RTRIM(product_id)), '')) AS last_product_no,
|
||||
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(product_id)), '')) AS product_count
|
||||
FROM dbo.bas_stock
|
||||
GROUP BY cargo_space_id
|
||||
)
|
||||
SELECT TOP 5 state.space_id, details.tray_code, details.project_no,
|
||||
details.first_product_no, details.last_product_no, details.product_count
|
||||
FROM pallet_details details
|
||||
JOIN alg.v_space_state state ON state.space_id = details.cargo_space_id
|
||||
WHERE details.product_count > 1
|
||||
AND state.space_status = '2'
|
||||
AND state.stock_tray_count = 1
|
||||
AND state.stock_project_count = 1
|
||||
AND state.tray_conflict = 0
|
||||
AND state.project_conflict = 0
|
||||
AND state.pending_outbound = 0
|
||||
AND state.reserved = 0
|
||||
AND state.has_gap = 0
|
||||
ORDER BY state.space_id;
|
||||
|
||||
SELECT space_id, warehouse_id, lane_code, depth, space_status, role,
|
||||
tray_conflict, project_conflict, product_conflict, pending_outbound, reserved, has_gap,
|
||||
inbound_available
|
||||
FROM alg.v_space_state
|
||||
WHERE tray_conflict = 1 OR project_conflict = 1 OR product_conflict = 1
|
||||
OR pending_outbound = 1 OR has_gap = 1
|
||||
ORDER BY warehouse_id, lane_code, depth;
|
||||
|
||||
SELECT reservation_id, warehouse_id, lane_code, space_id, project_no, status,
|
||||
created_at, released_at
|
||||
FROM alg.reservation
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
SELECT lock_id, request_id, warehouse_id, floor_no, status, created_at, released_at
|
||||
FROM alg.deep_workflow_lock
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
SELECT warehouse_id, lane_code, topology_dirty, reason_mask, change_count,
|
||||
enqueued_at, last_changed_at
|
||||
FROM alg.dirty_lane_queue
|
||||
ORDER BY last_changed_at, warehouse_id, lane_code;
|
||||
|
||||
SELECT workflow_id, request_id, stockout_detail_id, warehouse_id, source_stock_id,
|
||||
selected_tray_code, source_space_id, lane_code, floor_no, exit_id,
|
||||
status, current_step, total_steps, created_at, updated_at, completed_at
|
||||
FROM alg.outbound_workflow
|
||||
WHERE status <> 'COMPLETED'
|
||||
ORDER BY created_at, workflow_id;
|
||||
|
||||
SELECT step.workflow_id, step.assignment_id, step.phase, step.source_space_id,
|
||||
step.target_space_id, step.workflow_step, step.workflow_total_steps,
|
||||
step.depends_on_assignment_id, step.stock_id, step.tray_code, step.status,
|
||||
step.wms_job_id, step.created_at, step.started_at, step.at_exit_at, step.completed_at
|
||||
FROM alg.outbound_workflow_step step
|
||||
JOIN alg.outbound_workflow workflow ON workflow.workflow_id = step.workflow_id
|
||||
WHERE workflow.status <> 'COMPLETED' OR step.status <> 'COMPLETED'
|
||||
ORDER BY step.workflow_id, step.workflow_step;
|
||||
|
||||
SELECT mode.warehouse_id, mode.requested_enabled, mode.effective_state,
|
||||
mode.status_reason, mode.mode_version, mode.updated_by, mode.updated_at
|
||||
FROM alg.tidy_mode mode
|
||||
ORDER BY mode.warehouse_id;
|
||||
|
||||
SELECT queue.warehouse_id, queue.lane_code, queue.dirty_version, queue.queue_status,
|
||||
queue.reason_mask, queue.change_count, queue.last_dirty_at, queue.claimed_version,
|
||||
queue.claimed_by, queue.claimed_at, queue.lease_expires_at, queue.last_error
|
||||
FROM alg.tidy_lane_queue queue
|
||||
ORDER BY queue.last_dirty_at, queue.warehouse_id, queue.lane_code;
|
||||
|
||||
SELECT workflow.workflow_id, workflow.warehouse_id, workflow.lane_code, workflow.origin,
|
||||
workflow.candidate_type, workflow.source_space_id, workflow.target_space_id,
|
||||
workflow.stock_id, workflow.tray_code, workflow.status, workflow.claimed_dirty_version,
|
||||
workflow.vehicle_id, workflow.wcs_task_id, workflow.status_reason,
|
||||
workflow.created_at, workflow.updated_at, workflow.dispatched_at, workflow.completed_at
|
||||
FROM alg.tidy_workflow workflow
|
||||
WHERE workflow.completed_at IS NULL
|
||||
OR workflow.status IN ('PAUSED_RECOVERY', 'CANCELLED_RECONCILED')
|
||||
ORDER BY workflow.created_at, workflow.workflow_id;
|
||||
|
||||
SELECT step.workflow_id, step.step_id, step.step_no, step.source_space_id,
|
||||
step.target_space_id, step.stock_id, step.tray_code, step.status,
|
||||
step.wms_job_id, step.wcs_task_id, step.expected_event_stage,
|
||||
step.created_at, step.started_at, step.completed_at
|
||||
FROM alg.tidy_step step
|
||||
JOIN alg.tidy_workflow workflow ON workflow.workflow_id = step.workflow_id
|
||||
WHERE workflow.completed_at IS NULL OR step.completed_at IS NULL
|
||||
ORDER BY step.workflow_id, step.step_no;
|
||||
|
||||
SELECT lock.workflow_id, lock.warehouse_id, lock.resource_type, lock.resource_id,
|
||||
lock.status, lock.locked_at, lock.released_at
|
||||
FROM alg.tidy_space_lock lock
|
||||
WHERE lock.status = 'ACTIVE'
|
||||
ORDER BY lock.warehouse_id, lock.resource_type, lock.resource_id;
|
||||
|
||||
SELECT event.workflow_id, event.step_id, event.wcs_task_id, event.event_stage,
|
||||
COUNT(*) AS callback_count, MIN(event.received_at) AS first_received_at,
|
||||
MAX(event.received_at) AS last_received_at
|
||||
FROM alg.tidy_callback_event event
|
||||
GROUP BY event.workflow_id, event.step_id, event.wcs_task_id, event.event_stage
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY event.workflow_id, event.step_id, event.event_stage;
|
||||
|
||||
SELECT workflow.origin, workflow.status, COUNT(*) AS workflow_count
|
||||
FROM alg.tidy_workflow workflow
|
||||
GROUP BY workflow.origin, workflow.status
|
||||
ORDER BY workflow.origin, workflow.status;
|
||||
1936
fortucky-algorithm/src/main/resources/db/sqlserver/install.sql
Normal file
1936
fortucky-algorithm/src/main/resources/db/sqlserver/install.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
/* Removes only objects owned by schema alg. Run independently of install.sql. */
|
||||
SET NOCOUNT ON;
|
||||
|
||||
IF OBJECT_ID(N'dbo.trg_alg_dirty_bas_stock', N'TR') IS NOT NULL DROP TRIGGER dbo.trg_alg_dirty_bas_stock;
|
||||
IF OBJECT_ID(N'dbo.trg_alg_dirty_bas_cargo_space', N'TR') IS NOT NULL DROP TRIGGER dbo.trg_alg_dirty_bas_cargo_space;
|
||||
IF OBJECT_ID(N'dbo.trg_alg_dirty_mydo_job', N'TR') IS NOT NULL DROP TRIGGER dbo.trg_alg_dirty_mydo_job;
|
||||
IF OBJECT_ID(N'alg.trg_alg_dirty_space_role', N'TR') IS NOT NULL DROP TRIGGER alg.trg_alg_dirty_space_role;
|
||||
IF OBJECT_ID(N'alg.trg_alg_dirty_project_policy', N'TR') IS NOT NULL DROP TRIGGER alg.trg_alg_dirty_project_policy;
|
||||
IF OBJECT_ID(N'alg.trg_alg_dirty_warehouse_policy', N'TR') IS NOT NULL DROP TRIGGER alg.trg_alg_dirty_warehouse_policy;
|
||||
GO
|
||||
IF OBJECT_ID(N'alg.usp_refresh_dirty_lanes', N'P') IS NOT NULL DROP PROCEDURE alg.usp_refresh_dirty_lanes;
|
||||
IF OBJECT_ID(N'alg.usp_mark_dirty_lane', N'P') IS NOT NULL DROP PROCEDURE alg.usp_mark_dirty_lane;
|
||||
IF OBJECT_ID(N'alg.usp_claim_tidy_lane', N'P') IS NOT NULL DROP PROCEDURE alg.usp_claim_tidy_lane;
|
||||
IF OBJECT_ID(N'alg.usp_release_tidy_lane', N'P') IS NOT NULL DROP PROCEDURE alg.usp_release_tidy_lane;
|
||||
IF OBJECT_ID(N'alg.usp_complete_move_job', N'P') IS NOT NULL DROP PROCEDURE alg.usp_complete_move_job;
|
||||
IF OBJECT_ID(N'alg.usp_sync_replay_sequences', N'P') IS NOT NULL DROP PROCEDURE alg.usp_sync_replay_sequences;
|
||||
IF OBJECT_ID(N'alg.usp_release_reservation', N'P') IS NOT NULL DROP PROCEDURE alg.usp_release_reservation;
|
||||
IF OBJECT_ID(N'alg.usp_try_reserve_inbound', N'P') IS NOT NULL DROP PROCEDURE alg.usp_try_reserve_inbound;
|
||||
IF OBJECT_ID(N'alg.usp_apply_space_role', N'P') IS NOT NULL DROP PROCEDURE alg.usp_apply_space_role;
|
||||
IF OBJECT_ID(N'alg.usp_release_deep_workflow_lock', N'P') IS NOT NULL DROP PROCEDURE alg.usp_release_deep_workflow_lock;
|
||||
IF OBJECT_ID(N'alg.usp_acquire_deep_workflow_lock', N'P') IS NOT NULL DROP PROCEDURE alg.usp_acquire_deep_workflow_lock;
|
||||
IF OBJECT_ID(N'alg.usp_rebuild_lane_state', N'P') IS NOT NULL DROP PROCEDURE alg.usp_rebuild_lane_state;
|
||||
IF OBJECT_ID(N'alg.usp_compute_lane_semantic_fingerprint', N'P') IS NOT NULL DROP PROCEDURE alg.usp_compute_lane_semantic_fingerprint;
|
||||
IF OBJECT_ID(N'alg.usp_sync_inventory_snapshot', N'P') IS NOT NULL DROP PROCEDURE alg.usp_sync_inventory_snapshot;
|
||||
IF OBJECT_ID(N'alg.usp_rebuild_space_topology', N'P') IS NOT NULL DROP PROCEDURE alg.usp_rebuild_space_topology;
|
||||
GO
|
||||
IF OBJECT_ID(N'alg.v_space_state', N'V') IS NOT NULL DROP VIEW alg.v_space_state;
|
||||
IF OBJECT_ID(N'alg.v_active_job', N'V') IS NOT NULL DROP VIEW alg.v_active_job;
|
||||
IF OBJECT_ID(N'alg.v_inventory_item', N'V') IS NOT NULL DROP VIEW alg.v_inventory_item;
|
||||
GO
|
||||
IF OBJECT_ID(N'alg.outbound_workflow_space', N'U') IS NOT NULL DROP TABLE alg.outbound_workflow_space;
|
||||
IF OBJECT_ID(N'alg.outbound_workflow_step', N'U') IS NOT NULL DROP TABLE alg.outbound_workflow_step;
|
||||
IF OBJECT_ID(N'alg.outbound_workflow', N'U') IS NOT NULL DROP TABLE alg.outbound_workflow;
|
||||
IF OBJECT_ID(N'alg.tidy_callback_event', N'U') IS NOT NULL DROP TABLE alg.tidy_callback_event;
|
||||
IF OBJECT_ID(N'alg.tidy_space_lock', N'U') IS NOT NULL DROP TABLE alg.tidy_space_lock;
|
||||
IF OBJECT_ID(N'alg.tidy_step', N'U') IS NOT NULL DROP TABLE alg.tidy_step;
|
||||
IF OBJECT_ID(N'alg.tidy_workflow', N'U') IS NOT NULL DROP TABLE alg.tidy_workflow;
|
||||
IF OBJECT_ID(N'alg.tidy_lane_queue', N'U') IS NOT NULL DROP TABLE alg.tidy_lane_queue;
|
||||
IF OBJECT_ID(N'alg.tidy_mode', N'U') IS NOT NULL DROP TABLE alg.tidy_mode;
|
||||
IF OBJECT_ID(N'alg.decision_log', N'U') IS NOT NULL DROP TABLE alg.decision_log;
|
||||
IF OBJECT_ID(N'alg.deep_workflow_lock', N'U') IS NOT NULL DROP TABLE alg.deep_workflow_lock;
|
||||
IF OBJECT_ID(N'alg.reservation', N'U') IS NOT NULL DROP TABLE alg.reservation;
|
||||
IF OBJECT_ID(N'alg.dirty_lane_queue', N'U') IS NOT NULL DROP TABLE alg.dirty_lane_queue;
|
||||
IF OBJECT_ID(N'alg.lane_state', N'U') IS NOT NULL DROP TABLE alg.lane_state;
|
||||
IF OBJECT_ID(N'alg.space_inventory_summary', N'U') IS NOT NULL DROP TABLE alg.space_inventory_summary;
|
||||
IF OBJECT_ID(N'alg.inventory_item_snapshot', N'U') IS NOT NULL DROP TABLE alg.inventory_item_snapshot;
|
||||
IF OBJECT_ID(N'alg.space_topology', N'U') IS NOT NULL DROP TABLE alg.space_topology;
|
||||
IF OBJECT_ID(N'alg.space_role', N'U') IS NOT NULL DROP TABLE alg.space_role;
|
||||
IF OBJECT_ID(N'alg.project_policy', N'U') IS NOT NULL DROP TABLE alg.project_policy;
|
||||
IF OBJECT_ID(N'alg.warehouse_policy', N'U') IS NOT NULL DROP TABLE alg.warehouse_policy;
|
||||
GO
|
||||
IF SCHEMA_ID(N'alg') IS NOT NULL EXEC(N'DROP SCHEMA alg');
|
||||
GO
|
||||
Reference in New Issue
Block a user