325 lines
14 KiB
Java
325 lines
14 KiB
Java
package com.ruoyi.shadow.kernel;
|
|
|
|
import com.ruoyi.shadow.facts.ShadowFactBatch;
|
|
import com.ruoyi.shadow.facts.ShadowMapFact;
|
|
import com.ruoyi.shadow.facts.ShadowQueueFact;
|
|
import com.ruoyi.shadow.facts.ShadowQueueEndpoints;
|
|
import com.ruoyi.shadow.facts.ShadowVehicleFact;
|
|
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
|
|
import com.ruoyi.wcs.domain.dispatch.DispatchTask;
|
|
import com.ruoyi.wcs.domain.equipment.DeviceCapability;
|
|
import com.ruoyi.wcs.domain.equipment.DeviceDescriptor;
|
|
import com.ruoyi.wcs.domain.equipment.DeviceType;
|
|
import com.ruoyi.wcs.domain.equipment.VehicleSnapshot;
|
|
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
|
import com.ruoyi.wcs.domain.topology.ResourceDefinition;
|
|
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
|
|
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
|
|
import com.ruoyi.wcs.domain.topology.TopologyEdge;
|
|
import com.ruoyi.wcs.domain.topology.TopologyNode;
|
|
|
|
import java.time.Clock;
|
|
import java.time.Duration;
|
|
import java.time.Instant;
|
|
import java.util.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.Collections;
|
|
import java.util.EnumSet;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Objects;
|
|
import java.util.function.Function;
|
|
|
|
public final class ShadowDispatchSnapshotAssembler {
|
|
private static final String ACTIVE_QUEUE =
|
|
"shadow_ro.v_wcs_srgv_queue";
|
|
|
|
private final String warehouseCode;
|
|
private final long topologyVersion;
|
|
private final Function<EffectiveTopology, ShadowTrafficState> stateLoader;
|
|
private final Clock clock;
|
|
private final java.util.Set<String> safeWaitSites;
|
|
private final java.util.Set<String> safeYieldSites;
|
|
private final ShadowObservedTrafficProjector observedTraffic = new ShadowObservedTrafficProjector();
|
|
|
|
public ShadowDispatchSnapshotAssembler(
|
|
String warehouseCode,
|
|
long topologyVersion,
|
|
Function<EffectiveTopology, ShadowTrafficState> stateLoader,
|
|
Clock clock) {
|
|
this(warehouseCode, topologyVersion, stateLoader, clock, Collections.emptySet(), Collections.emptySet());
|
|
}
|
|
|
|
public ShadowDispatchSnapshotAssembler(String warehouseCode, long topologyVersion,
|
|
Function<EffectiveTopology, ShadowTrafficState> stateLoader, Clock clock,
|
|
java.util.Set<String> safeWaitSites, java.util.Set<String> safeYieldSites) {
|
|
this.warehouseCode = required(warehouseCode, "warehouseCode");
|
|
if (topologyVersion <= 0L) {
|
|
throw new IllegalArgumentException(
|
|
"topologyVersion must be positive");
|
|
}
|
|
this.topologyVersion = topologyVersion;
|
|
this.stateLoader = Objects.requireNonNull(stateLoader, "stateLoader");
|
|
this.clock = Objects.requireNonNull(clock, "clock");
|
|
this.safeWaitSites = safeSites(safeWaitSites);
|
|
this.safeYieldSites = safeSites(safeYieldSites);
|
|
}
|
|
|
|
public DispatchSnapshot assemble(ShadowFactBatch batch) {
|
|
Objects.requireNonNull(batch, "batch");
|
|
EffectiveTopology topology = topology(batch.getMaps());
|
|
ShadowTrafficState state = Objects.requireNonNull(
|
|
stateLoader.apply(topology), "stateLoader result");
|
|
return new DispatchSnapshot(tasks(batch, topology),
|
|
vehicles(batch, topology), topology, state.getLedger(),
|
|
state.getWaitForGraph(), state.getSnapshotVersion(), observedTraffic.project(batch, topology));
|
|
}
|
|
|
|
private EffectiveTopology topology(List<ShadowMapFact> facts) {
|
|
List<TopologyNode> nodes = new ArrayList<TopologyNode>();
|
|
Map<String, ShadowMapFact> coordinates =
|
|
new LinkedHashMap<String, ShadowMapFact>();
|
|
for (ShadowMapFact fact : facts) {
|
|
if (fact.getValue() != 1 && fact.getValue() != 2) {
|
|
continue;
|
|
}
|
|
nodes.add(new TopologyNode(fact.getSiteId(), floor(fact.getZ()),
|
|
fact.getX(), fact.getY(), 1, safeWaitSites.contains(fact.getSiteId()),
|
|
safeYieldSites.contains(fact.getSiteId())));
|
|
String coordinate = coordinate(fact.getX(), fact.getY(), fact.getZ());
|
|
if (coordinates.put(coordinate, fact) != null) {
|
|
throw new IllegalArgumentException(
|
|
"duplicate passable map coordinate: " + coordinate);
|
|
}
|
|
}
|
|
if (nodes.isEmpty()) {
|
|
throw new IllegalArgumentException("map has no passable nodes");
|
|
}
|
|
List<TopologyEdge> edges = new ArrayList<TopologyEdge>();
|
|
for (ShadowMapFact fact : coordinates.values()) {
|
|
addEdge(edges, fact, coordinates.get(coordinate(
|
|
fact.getX() + 1, fact.getY(), fact.getZ())));
|
|
addEdge(edges, fact, coordinates.get(coordinate(
|
|
fact.getX(), fact.getY() + 1, fact.getZ())));
|
|
}
|
|
TopologyDefinition definition = new TopologyDefinition(warehouseCode,
|
|
topologyVersion, true, nodes, edges,
|
|
Collections.<ResourceDefinition>emptyList(),
|
|
Collections.<String>emptyList(),
|
|
Collections.<String>emptyList());
|
|
EffectiveTopology topology = new TopologyCompiler().compile(definition);
|
|
java.util.Set<String> configured = new java.util.LinkedHashSet<String>(safeWaitSites);
|
|
configured.addAll(safeYieldSites);
|
|
for (String id : configured) {
|
|
if (!topology.containsNode(id) || topology.neighbors(id).size() < 2) {
|
|
throw new IllegalArgumentException("INVALID_CONFIGURED_SAFE_SITE:" + id);
|
|
}
|
|
}
|
|
return topology;
|
|
}
|
|
|
|
public static java.util.Set<String> safeSites(java.util.Set<String> values) {
|
|
java.util.Set<String> normalized = new java.util.TreeSet<String>();
|
|
for (String value : Objects.requireNonNull(values, "safeSites")) {
|
|
String id = required(value, "safeSite");
|
|
if (!id.matches("[0-9]{1,6}") || Integer.parseInt(id) == 0)
|
|
throw new IllegalArgumentException("INVALID_CONFIGURED_SAFE_SITE:" + id);
|
|
normalized.add(id);
|
|
}
|
|
return Collections.unmodifiableSet(normalized);
|
|
}
|
|
|
|
private List<DispatchTask> tasks(ShadowFactBatch batch,
|
|
EffectiveTopology topology) {
|
|
List<DispatchTask> tasks = new ArrayList<DispatchTask>();
|
|
Instant now = clock.instant();
|
|
for (ShadowQueueFact fact : batch.getQueues()) {
|
|
if (com.ruoyi.shadow.facts.ShadowQueueDispatchEligibility.observationReason(fact, batch) != null) {
|
|
continue;
|
|
}
|
|
String source = required(ShadowQueueEndpoints.dispatchStart(fact, batch), "startSite");
|
|
String destination = required(ShadowQueueEndpoints.end(fact, batch), "endSite");
|
|
if (!topology.containsNode(source)
|
|
|| !topology.containsNode(destination)) {
|
|
throw new IllegalArgumentException(
|
|
"active queue endpoint is outside passable topology: "
|
|
+ fact.getGuid());
|
|
}
|
|
Map<String, String> command = commandParameters(fact);
|
|
tasks.add(new DispatchTask(required(fact.getTaskNo(), "taskNo"),
|
|
fact.getGuid(), source, destination, priority(fact),
|
|
waitingAge(fact.getCreatedAt(), now), 31,
|
|
EnumSet.of(DeviceCapability.FOUR_WAY_MOVE), command));
|
|
}
|
|
return tasks;
|
|
}
|
|
|
|
private Map<String, String> commandParameters(ShadowQueueFact fact) {
|
|
Map<String, String> values = new LinkedHashMap<String, String>();
|
|
String parentTaskId = required(fact.getTaskNo(), "taskNo");
|
|
values.put("parentTaskId", parentTaskId);
|
|
values.put("phase", phase(required(fact.getTaskType(), "taskType")));
|
|
values.put("routeVersion", Long.toString(topologyVersion));
|
|
String boundVehicle = optional(fact.getAgvId());
|
|
if (boundVehicle != null && !"0".equals(boundVehicle)) {
|
|
values.put("shadowBoundVehicleId", boundVehicle);
|
|
}
|
|
values.put("taskNo", positiveNumber(parentTaskId, "taskNo"));
|
|
short taskType = parsedShort(required(fact.getDefault02(), "default02"),
|
|
"default02");
|
|
values.put("taskType", Short.toString(taskType));
|
|
String stage = optional(fact.getDefault08());
|
|
if (stage != null) {
|
|
if (!"1".equals(stage) && !"2".equals(stage)) {
|
|
throw new IllegalArgumentException("default08 must be 1 or 2");
|
|
}
|
|
values.put("stage", stage);
|
|
}
|
|
String target = optional(fact.getDefault07());
|
|
if (taskType == 8) {
|
|
if (target == null) {
|
|
throw new IllegalArgumentException(
|
|
"default07 is required for cross-floor task");
|
|
}
|
|
values.put("crossFloorTarget", positiveNumber(target,
|
|
"default07"));
|
|
}
|
|
if (stage != null && taskType == 8) {
|
|
throw new IllegalArgumentException(
|
|
"cross-floor and two-stage parameters cannot be combined");
|
|
}
|
|
return values;
|
|
}
|
|
|
|
private List<VehicleSnapshot> vehicles(ShadowFactBatch batch,
|
|
EffectiveTopology topology) {
|
|
List<VehicleSnapshot> result = new ArrayList<VehicleSnapshot>();
|
|
for (ShadowVehicleFact fact : batch.getVehicles()) {
|
|
String location = required(fact.getCurrentSite(), "currentSite");
|
|
if (!topology.containsNode(location)) {
|
|
throw new IllegalArgumentException(
|
|
"vehicle is outside passable topology: "
|
|
+ fact.getDeviceId());
|
|
}
|
|
String floor = fact.getCurrentFloor() == null
|
|
? topology.node(location).getFloorCode()
|
|
: floor(fact.getCurrentFloor().intValue());
|
|
DeviceDescriptor descriptor = new DeviceDescriptor(
|
|
fact.getDeviceId(), DeviceType.FOUR_WAY_SHUTTLE, floor,
|
|
EnumSet.of(DeviceCapability.FOUR_WAY_MOVE,
|
|
DeviceCapability.LOAD_HANDLING,
|
|
DeviceCapability.LIFT_TRANSFER));
|
|
// Read-only projection of the legacy PLC gate; agvStatus is not refreshed by PLC polling.
|
|
boolean connectedAndSendable = Integer.valueOf(5).equals(fact.getWorkMode())
|
|
&& Integer.valueOf(1).equals(fact.getAllowSend());
|
|
boolean healthy = zeroOrMissing(fact.getAlarm())
|
|
&& zeroOrMissing(fact.getErrorCode());
|
|
boolean idle = Integer.valueOf(1).equals(fact.getStatus());
|
|
result.add(new VehicleSnapshot(descriptor,
|
|
connectedAndSendable
|
|
? VehicleSnapshot.ConnectionState.ONLINE
|
|
: VehicleSnapshot.ConnectionState.OFFLINE,
|
|
Integer.valueOf(5).equals(fact.getWorkMode())
|
|
? VehicleSnapshot.OperatingMode.AUTO
|
|
: VehicleSnapshot.OperatingMode.MANUAL,
|
|
healthy ? VehicleSnapshot.HealthState.NORMAL
|
|
: VehicleSnapshot.HealthState.FAULT,
|
|
idle ? VehicleSnapshot.OccupationState.IDLE
|
|
: VehicleSnapshot.OccupationState.BUSY,
|
|
location,
|
|
Integer.valueOf(1).equals(fact.getCargoFlag())
|
|
? "LOAD_PRESENT" : null,
|
|
fact.getBattery().intValue(),
|
|
positive(fact.getTaskNo())
|
|
? Integer.toString(fact.getTaskNo()) : null,
|
|
floor, fact.getCapturedAt()));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static void addEdge(List<TopologyEdge> edges,
|
|
ShadowMapFact first,
|
|
ShadowMapFact second) {
|
|
if (second != null) {
|
|
edges.add(new TopologyEdge(first.getSiteId(), second.getSiteId(),
|
|
1, 1));
|
|
}
|
|
}
|
|
|
|
private static int priority(ShadowQueueFact fact) {
|
|
if (fact.getPriority() == null) {
|
|
return Integer.MIN_VALUE;
|
|
}
|
|
return (int) Math.max(Integer.MIN_VALUE,
|
|
Math.min(Integer.MAX_VALUE, fact.getPriority().longValue()));
|
|
}
|
|
|
|
private static long waitingAge(Instant createdAt, Instant now) {
|
|
if (createdAt == null || createdAt.isAfter(now)) {
|
|
throw new IllegalArgumentException(
|
|
"active queue createdAt must not be missing or future");
|
|
}
|
|
return Duration.between(createdAt, now).getSeconds();
|
|
}
|
|
|
|
private static String phase(String taskType) {
|
|
if ("1".equals(taskType)) {
|
|
return "EMPTY_TO_PICKUP";
|
|
}
|
|
if ("2".equals(taskType)) {
|
|
return "LOADED_TO_PLACE";
|
|
}
|
|
return "LEGACY_PHASE_" + taskType;
|
|
}
|
|
|
|
private static String floor(int value) {
|
|
return "F" + value;
|
|
}
|
|
|
|
private static String coordinate(int x, int y, int z) {
|
|
return z + ":" + x + ":" + y;
|
|
}
|
|
|
|
private static boolean zeroOrMissing(Integer value) {
|
|
return value == null || value.intValue() == 0;
|
|
}
|
|
|
|
private static boolean positive(Integer value) {
|
|
return value != null && value.intValue() > 0;
|
|
}
|
|
|
|
private static String positiveNumber(String value, String field) {
|
|
long parsed;
|
|
try {
|
|
parsed = Long.parseLong(required(value, field));
|
|
} catch (NumberFormatException failure) {
|
|
throw new IllegalArgumentException(field + " must be numeric", failure);
|
|
}
|
|
if (parsed <= 0L) {
|
|
throw new IllegalArgumentException(field + " must be positive");
|
|
}
|
|
return Long.toString(parsed);
|
|
}
|
|
|
|
private static short parsedShort(String value, String field) {
|
|
try {
|
|
return Short.parseShort(value);
|
|
} catch (NumberFormatException failure) {
|
|
throw new IllegalArgumentException(field + " must be int16", failure);
|
|
}
|
|
}
|
|
|
|
private static String optional(String value) {
|
|
return value == null || value.trim().isEmpty() ? null : value.trim();
|
|
}
|
|
|
|
private static String required(String value, String field) {
|
|
String normalized = optional(value);
|
|
if (normalized == null) {
|
|
throw new IllegalArgumentException(field + " must not be blank");
|
|
}
|
|
return normalized;
|
|
}
|
|
}
|