feat(wcs): publish immutable warehouse configuration
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
package com.ruoyi.wcs.application.configuration;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||||
|
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
||||||
|
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
|
||||||
|
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
|
||||||
|
import com.ruoyi.wcs.port.in.ConfigurationUseCase;
|
||||||
|
import com.ruoyi.wcs.port.out.ConfigurationRepository;
|
||||||
|
import com.ruoyi.wcs.port.out.TopologyDefinitionProvider;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public final class ConfigurationApplicationService implements ConfigurationUseCase {
|
||||||
|
private final TopologyDefinitionProvider definitions;
|
||||||
|
private final TopologyCompiler compiler;
|
||||||
|
private final ConfigurationRepository repository;
|
||||||
|
|
||||||
|
public ConfigurationApplicationService(TopologyDefinitionProvider definitions,
|
||||||
|
TopologyCompiler compiler,
|
||||||
|
ConfigurationRepository repository) {
|
||||||
|
this.definitions = Objects.requireNonNull(definitions, "definitions");
|
||||||
|
this.compiler = Objects.requireNonNull(compiler, "compiler");
|
||||||
|
this.repository = Objects.requireNonNull(repository, "repository");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void validate(PublishConfigurationCommand command) {
|
||||||
|
compile(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ConfigurationPublicationResult publish(PublishConfigurationCommand command) {
|
||||||
|
EffectiveTopology topology = compile(command);
|
||||||
|
PublishedConfigurationView active = repository.findActive(command.getWarehouseCode());
|
||||||
|
if (active != null && active.getTopologyVersion() != command.getVersion()
|
||||||
|
&& repository.hasActiveOrderReferences(command.getWarehouseCode(),
|
||||||
|
active.getTopologyVersion())) {
|
||||||
|
throw new DomainRuleViolation("active orders reference configuration version "
|
||||||
|
+ active.getTopologyVersion());
|
||||||
|
}
|
||||||
|
repository.publish(command, topology);
|
||||||
|
return new ConfigurationPublicationResult(command.getWarehouseCode(),
|
||||||
|
command.getVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublishedConfigurationView read(String warehouseCode, long version) {
|
||||||
|
PublishedConfigurationView view = repository.find(
|
||||||
|
requireText(warehouseCode, "warehouseCode"), version);
|
||||||
|
if (view == null) {
|
||||||
|
throw new DomainRuleViolation("configuration version not found: " + version);
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublishedConfigurationView readActive(String warehouseCode) {
|
||||||
|
PublishedConfigurationView view = repository.findActive(
|
||||||
|
requireText(warehouseCode, "warehouseCode"));
|
||||||
|
if (view == null) {
|
||||||
|
throw new DomainRuleViolation("active configuration not found");
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EffectiveTopology compile(PublishConfigurationCommand command) {
|
||||||
|
Objects.requireNonNull(command, "command");
|
||||||
|
TopologyDefinition definition = definitions.load(
|
||||||
|
command.getWarehouseCode(), command.getVersion());
|
||||||
|
if (definition == null) {
|
||||||
|
throw new DomainRuleViolation("topology definition not found");
|
||||||
|
}
|
||||||
|
if (!command.getWarehouseCode().equals(definition.getWarehouseCode())
|
||||||
|
|| command.getVersion() != definition.getVersion()) {
|
||||||
|
throw new DomainRuleViolation("topology definition identity mismatch");
|
||||||
|
}
|
||||||
|
return compiler.compile(definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireText(String value, String field) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(field + " must not be blank");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.ruoyi.wcs.application.configuration;
|
||||||
|
|
||||||
|
public final class ConfigurationPublicationResult {
|
||||||
|
private final String warehouseCode;
|
||||||
|
private final long publishedVersion;
|
||||||
|
|
||||||
|
public ConfigurationPublicationResult(String warehouseCode, long publishedVersion) {
|
||||||
|
this.warehouseCode = warehouseCode;
|
||||||
|
this.publishedVersion = publishedVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWarehouseCode() {
|
||||||
|
return warehouseCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getPublishedVersion() {
|
||||||
|
return publishedVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.ruoyi.wcs.application.configuration;
|
||||||
|
|
||||||
|
public final class PublishConfigurationCommand {
|
||||||
|
private final String warehouseCode;
|
||||||
|
private final long version;
|
||||||
|
|
||||||
|
public PublishConfigurationCommand(String warehouseCode, long version) {
|
||||||
|
if (warehouseCode == null || warehouseCode.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("warehouseCode must not be blank");
|
||||||
|
}
|
||||||
|
this.warehouseCode = warehouseCode.trim();
|
||||||
|
this.version = version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWarehouseCode() {
|
||||||
|
return warehouseCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getVersion() {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.ruoyi.wcs.application.configuration;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public final class PublishedConfigurationView {
|
||||||
|
private final String warehouseCode;
|
||||||
|
private final long topologyVersion;
|
||||||
|
private final long deviceConfigurationVersion;
|
||||||
|
private final long workflowConfigurationVersion;
|
||||||
|
private final EffectiveTopology topology;
|
||||||
|
|
||||||
|
private PublishedConfigurationView(String warehouseCode,
|
||||||
|
long topologyVersion,
|
||||||
|
long deviceConfigurationVersion,
|
||||||
|
long workflowConfigurationVersion,
|
||||||
|
EffectiveTopology topology) {
|
||||||
|
this.warehouseCode = warehouseCode;
|
||||||
|
this.topologyVersion = topologyVersion;
|
||||||
|
this.deviceConfigurationVersion = deviceConfigurationVersion;
|
||||||
|
this.workflowConfigurationVersion = workflowConfigurationVersion;
|
||||||
|
this.topology = topology;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PublishedConfigurationView from(EffectiveTopology topology) {
|
||||||
|
Objects.requireNonNull(topology, "topology");
|
||||||
|
long version = topology.version();
|
||||||
|
return new PublishedConfigurationView(topology.getWarehouseCode(),
|
||||||
|
version, version, version, topology);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWarehouseCode() {
|
||||||
|
return warehouseCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTopologyVersion() {
|
||||||
|
return topologyVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getDeviceConfigurationVersion() {
|
||||||
|
return deviceConfigurationVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getWorkflowConfigurationVersion() {
|
||||||
|
return workflowConfigurationVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EffectiveTopology getTopology() {
|
||||||
|
return topology;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.ruoyi.wcs.domain.topology;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class EffectiveTopology {
|
||||||
|
private final String warehouseCode;
|
||||||
|
private final long version;
|
||||||
|
private final Map<String, TopologyNode> nodes;
|
||||||
|
private final Map<String, TopologyEdge> edges;
|
||||||
|
private final Map<String, ResourceDefinition> resources;
|
||||||
|
private final Map<String, List<String>> neighbors;
|
||||||
|
|
||||||
|
EffectiveTopology(String warehouseCode, long version,
|
||||||
|
Map<String, TopologyNode> nodes,
|
||||||
|
Map<String, TopologyEdge> edges,
|
||||||
|
Map<String, ResourceDefinition> resources,
|
||||||
|
Map<String, List<String>> neighbors) {
|
||||||
|
this.warehouseCode = warehouseCode;
|
||||||
|
this.version = version;
|
||||||
|
this.nodes = Collections.unmodifiableMap(
|
||||||
|
new LinkedHashMap<String, TopologyNode>(nodes));
|
||||||
|
this.edges = Collections.unmodifiableMap(
|
||||||
|
new LinkedHashMap<String, TopologyEdge>(edges));
|
||||||
|
this.resources = Collections.unmodifiableMap(
|
||||||
|
new LinkedHashMap<String, ResourceDefinition>(resources));
|
||||||
|
Map<String, List<String>> copiedNeighbors =
|
||||||
|
new LinkedHashMap<String, List<String>>();
|
||||||
|
for (Map.Entry<String, List<String>> entry : neighbors.entrySet()) {
|
||||||
|
copiedNeighbors.put(entry.getKey(), Collections.unmodifiableList(
|
||||||
|
new ArrayList<String>(entry.getValue())));
|
||||||
|
}
|
||||||
|
this.neighbors = Collections.unmodifiableMap(copiedNeighbors);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWarehouseCode() {
|
||||||
|
return warehouseCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long version() {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean containsNode(String nodeId) {
|
||||||
|
return nodes.containsKey(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TopologyNode node(String nodeId) {
|
||||||
|
return nodes.get(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> neighbors(String nodeId) {
|
||||||
|
List<String> result = neighbors.get(nodeId);
|
||||||
|
return result == null ? Collections.<String>emptyList() : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TopologyEdge edge(String sourceNodeId, String targetNodeId) {
|
||||||
|
return edges.get(TopologyEdge.canonicalResourceKey(sourceNodeId, targetNodeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResourceDefinition resource(String resourceKey) {
|
||||||
|
return resources.get(resourceKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, ResourceDefinition> getResources() {
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.ruoyi.wcs.domain.topology;
|
||||||
|
|
||||||
|
public final class ResourceDefinition {
|
||||||
|
private final String resourceKey;
|
||||||
|
private final int capacity;
|
||||||
|
|
||||||
|
public ResourceDefinition(String resourceKey, int capacity) {
|
||||||
|
if (resourceKey == null || resourceKey.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("resourceKey must not be blank");
|
||||||
|
}
|
||||||
|
this.resourceKey = resourceKey.trim();
|
||||||
|
this.capacity = capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getResourceKey() {
|
||||||
|
return resourceKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCapacity() {
|
||||||
|
return capacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.ruoyi.wcs.domain.topology;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public final class TopologyCompiler {
|
||||||
|
|
||||||
|
public EffectiveTopology compile(TopologyDefinition definition) {
|
||||||
|
if (definition == null) {
|
||||||
|
throw new DomainRuleViolation("topology definition is required");
|
||||||
|
}
|
||||||
|
if (!definition.isConfirmed()) {
|
||||||
|
throw new DomainRuleViolation("topology configuration is not confirmed");
|
||||||
|
}
|
||||||
|
if (definition.getVersion() <= 0L) {
|
||||||
|
throw new DomainRuleViolation("topology version must be positive");
|
||||||
|
}
|
||||||
|
if (definition.getNodes().isEmpty()) {
|
||||||
|
throw new DomainRuleViolation("topology must contain at least one node");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, TopologyNode> nodes = uniqueNodes(definition.getNodes());
|
||||||
|
Map<String, List<String>> neighbors = emptyNeighbors(nodes);
|
||||||
|
Map<String, ResourceDefinition> resources = nodeResources(nodes);
|
||||||
|
Map<String, TopologyEdge> edges = compileEdges(
|
||||||
|
definition.getEdges(), nodes, neighbors, resources);
|
||||||
|
|
||||||
|
validatePorts("lift", definition.getLiftPortNodeIds(), nodes);
|
||||||
|
validatePorts("conveyor", definition.getConveyorPortNodeIds(), nodes);
|
||||||
|
validateSafeWaitNodes(nodes, neighbors);
|
||||||
|
applyExplicitResources(definition.getResources(), resources);
|
||||||
|
|
||||||
|
for (List<String> adjacent : neighbors.values()) {
|
||||||
|
Collections.sort(adjacent);
|
||||||
|
}
|
||||||
|
return new EffectiveTopology(definition.getWarehouseCode(),
|
||||||
|
definition.getVersion(), nodes, edges, resources, neighbors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, TopologyNode> uniqueNodes(List<TopologyNode> definitions) {
|
||||||
|
Map<String, TopologyNode> nodes = new LinkedHashMap<String, TopologyNode>();
|
||||||
|
Set<String> coordinates = new HashSet<String>();
|
||||||
|
for (TopologyNode node : definitions) {
|
||||||
|
if (node == null) {
|
||||||
|
throw new DomainRuleViolation("topology node must not be null");
|
||||||
|
}
|
||||||
|
if (node.getCapacity() <= 0) {
|
||||||
|
throw new DomainRuleViolation("node capacity must be positive: "
|
||||||
|
+ node.getNodeId());
|
||||||
|
}
|
||||||
|
if (nodes.put(node.getNodeId(), node) != null) {
|
||||||
|
throw new DomainRuleViolation("duplicate topology node: "
|
||||||
|
+ node.getNodeId());
|
||||||
|
}
|
||||||
|
String coordinate = node.getFloorCode() + ":" + node.getX() + ":" + node.getY();
|
||||||
|
if (!coordinates.add(coordinate)) {
|
||||||
|
throw new DomainRuleViolation("duplicate topology coordinate: " + coordinate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, List<String>> emptyNeighbors(
|
||||||
|
Map<String, TopologyNode> nodes) {
|
||||||
|
Map<String, List<String>> neighbors =
|
||||||
|
new LinkedHashMap<String, List<String>>();
|
||||||
|
for (String nodeId : nodes.keySet()) {
|
||||||
|
neighbors.put(nodeId, new ArrayList<String>());
|
||||||
|
}
|
||||||
|
return neighbors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, ResourceDefinition> nodeResources(
|
||||||
|
Map<String, TopologyNode> nodes) {
|
||||||
|
Map<String, ResourceDefinition> resources =
|
||||||
|
new LinkedHashMap<String, ResourceDefinition>();
|
||||||
|
for (TopologyNode node : nodes.values()) {
|
||||||
|
ResourceDefinition resource = new ResourceDefinition(
|
||||||
|
"NODE:" + node.getNodeId(), node.getCapacity());
|
||||||
|
resources.put(resource.getResourceKey(), resource);
|
||||||
|
}
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, TopologyEdge> compileEdges(
|
||||||
|
List<TopologyEdge> definitions,
|
||||||
|
Map<String, TopologyNode> nodes,
|
||||||
|
Map<String, List<String>> neighbors,
|
||||||
|
Map<String, ResourceDefinition> resources) {
|
||||||
|
Map<String, TopologyEdge> edges = new LinkedHashMap<String, TopologyEdge>();
|
||||||
|
for (TopologyEdge edge : definitions) {
|
||||||
|
if (edge == null) {
|
||||||
|
throw new DomainRuleViolation("topology edge must not be null");
|
||||||
|
}
|
||||||
|
if (!nodes.containsKey(edge.getSourceNodeId())
|
||||||
|
|| !nodes.containsKey(edge.getTargetNodeId())) {
|
||||||
|
throw new DomainRuleViolation("edge references unknown node: "
|
||||||
|
+ edge.getResourceKey());
|
||||||
|
}
|
||||||
|
if (edge.getSourceNodeId().equals(edge.getTargetNodeId())) {
|
||||||
|
throw new DomainRuleViolation("self edge is not allowed: "
|
||||||
|
+ edge.getSourceNodeId());
|
||||||
|
}
|
||||||
|
if (edge.getDistanceCost() <= 0 || edge.getCapacity() <= 0) {
|
||||||
|
throw new DomainRuleViolation("edge distance and capacity must be positive: "
|
||||||
|
+ edge.getResourceKey());
|
||||||
|
}
|
||||||
|
if (edges.put(edge.getResourceKey(), edge) != null) {
|
||||||
|
throw new DomainRuleViolation("duplicate topology edge: "
|
||||||
|
+ edge.getResourceKey());
|
||||||
|
}
|
||||||
|
neighbors.get(edge.getSourceNodeId()).add(edge.getTargetNodeId());
|
||||||
|
neighbors.get(edge.getTargetNodeId()).add(edge.getSourceNodeId());
|
||||||
|
resources.put(edge.getResourceKey(), new ResourceDefinition(
|
||||||
|
edge.getResourceKey(), edge.getCapacity()));
|
||||||
|
}
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validatePorts(String type, List<String> portNodeIds,
|
||||||
|
Map<String, TopologyNode> nodes) {
|
||||||
|
Set<String> seen = new HashSet<String>();
|
||||||
|
for (String nodeId : portNodeIds) {
|
||||||
|
if (nodeId == null || !nodes.containsKey(nodeId)) {
|
||||||
|
throw new DomainRuleViolation(type
|
||||||
|
+ " port references unknown node: " + nodeId);
|
||||||
|
}
|
||||||
|
if (!seen.add(nodeId)) {
|
||||||
|
throw new DomainRuleViolation("duplicate " + type + " port: " + nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateSafeWaitNodes(Map<String, TopologyNode> nodes,
|
||||||
|
Map<String, List<String>> neighbors) {
|
||||||
|
for (TopologyNode node : nodes.values()) {
|
||||||
|
if (node.isSafeWait() && neighbors.get(node.getNodeId()).size() <= 1) {
|
||||||
|
throw new DomainRuleViolation("safe wait node is a unique exit: "
|
||||||
|
+ node.getNodeId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applyExplicitResources(
|
||||||
|
List<ResourceDefinition> definitions,
|
||||||
|
Map<String, ResourceDefinition> resources) {
|
||||||
|
Set<String> explicitKeys = new HashSet<String>();
|
||||||
|
for (ResourceDefinition resource : definitions) {
|
||||||
|
if (resource == null) {
|
||||||
|
throw new DomainRuleViolation("resource definition must not be null");
|
||||||
|
}
|
||||||
|
if (resource.getCapacity() <= 0) {
|
||||||
|
throw new DomainRuleViolation("resource capacity must be positive: "
|
||||||
|
+ resource.getResourceKey());
|
||||||
|
}
|
||||||
|
if (!explicitKeys.add(resource.getResourceKey())) {
|
||||||
|
throw new DomainRuleViolation("duplicate resource definition: "
|
||||||
|
+ resource.getResourceKey());
|
||||||
|
}
|
||||||
|
if (!resource.getResourceKey().startsWith("CONFLICT:")
|
||||||
|
&& !resources.containsKey(resource.getResourceKey())) {
|
||||||
|
throw new DomainRuleViolation("resource references unknown topology item: "
|
||||||
|
+ resource.getResourceKey());
|
||||||
|
}
|
||||||
|
resources.put(resource.getResourceKey(), resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.ruoyi.wcs.domain.topology;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class TopologyDefinition {
|
||||||
|
private final String warehouseCode;
|
||||||
|
private final long version;
|
||||||
|
private final boolean confirmed;
|
||||||
|
private final List<TopologyNode> nodes;
|
||||||
|
private final List<TopologyEdge> edges;
|
||||||
|
private final List<ResourceDefinition> resources;
|
||||||
|
private final List<String> liftPortNodeIds;
|
||||||
|
private final List<String> conveyorPortNodeIds;
|
||||||
|
|
||||||
|
public TopologyDefinition(String warehouseCode, long version, boolean confirmed,
|
||||||
|
List<TopologyNode> nodes, List<TopologyEdge> edges,
|
||||||
|
List<ResourceDefinition> resources,
|
||||||
|
List<String> liftPortNodeIds,
|
||||||
|
List<String> conveyorPortNodeIds) {
|
||||||
|
if (warehouseCode == null || warehouseCode.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("warehouseCode must not be blank");
|
||||||
|
}
|
||||||
|
this.warehouseCode = warehouseCode.trim();
|
||||||
|
this.version = version;
|
||||||
|
this.confirmed = confirmed;
|
||||||
|
this.nodes = immutable(nodes, "nodes");
|
||||||
|
this.edges = immutable(edges, "edges");
|
||||||
|
this.resources = immutable(resources, "resources");
|
||||||
|
this.liftPortNodeIds = immutable(liftPortNodeIds, "liftPortNodeIds");
|
||||||
|
this.conveyorPortNodeIds = immutable(conveyorPortNodeIds,
|
||||||
|
"conveyorPortNodeIds");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWarehouseCode() {
|
||||||
|
return warehouseCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getVersion() {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConfirmed() {
|
||||||
|
return confirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TopologyNode> getNodes() {
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TopologyEdge> getEdges() {
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ResourceDefinition> getResources() {
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getLiftPortNodeIds() {
|
||||||
|
return liftPortNodeIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getConveyorPortNodeIds() {
|
||||||
|
return conveyorPortNodeIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> List<T> immutable(List<T> source, String field) {
|
||||||
|
if (source == null) {
|
||||||
|
throw new IllegalArgumentException(field + " must not be null");
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableList(new ArrayList<T>(source));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.ruoyi.wcs.domain.topology;
|
||||||
|
|
||||||
|
public final class TopologyEdge {
|
||||||
|
private final String sourceNodeId;
|
||||||
|
private final String targetNodeId;
|
||||||
|
private final int distanceCost;
|
||||||
|
private final int capacity;
|
||||||
|
private final String resourceKey;
|
||||||
|
|
||||||
|
public TopologyEdge(String sourceNodeId, String targetNodeId,
|
||||||
|
int distanceCost, int capacity) {
|
||||||
|
this.sourceNodeId = requireText(sourceNodeId, "sourceNodeId");
|
||||||
|
this.targetNodeId = requireText(targetNodeId, "targetNodeId");
|
||||||
|
this.distanceCost = distanceCost;
|
||||||
|
this.capacity = capacity;
|
||||||
|
this.resourceKey = canonicalResourceKey(this.sourceNodeId, this.targetNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSourceNodeId() {
|
||||||
|
return sourceNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTargetNodeId() {
|
||||||
|
return targetNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getDistanceCost() {
|
||||||
|
return distanceCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCapacity() {
|
||||||
|
return capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getResourceKey() {
|
||||||
|
return resourceKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String other(String nodeId) {
|
||||||
|
if (sourceNodeId.equals(nodeId)) {
|
||||||
|
return targetNodeId;
|
||||||
|
}
|
||||||
|
if (targetNodeId.equals(nodeId)) {
|
||||||
|
return sourceNodeId;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("node is not an endpoint: " + nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String canonicalResourceKey(String first, String second) {
|
||||||
|
String a = requireText(first, "first");
|
||||||
|
String b = requireText(second, "second");
|
||||||
|
return a.compareTo(b) <= 0
|
||||||
|
? "EDGE:" + a + ":" + b
|
||||||
|
: "EDGE:" + b + ":" + a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireText(String value, String field) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(field + " must not be blank");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.ruoyi.wcs.domain.topology;
|
||||||
|
|
||||||
|
public final class TopologyNode {
|
||||||
|
private final String nodeId;
|
||||||
|
private final String floorCode;
|
||||||
|
private final int x;
|
||||||
|
private final int y;
|
||||||
|
private final int capacity;
|
||||||
|
private final boolean safeWait;
|
||||||
|
private final boolean safeYield;
|
||||||
|
|
||||||
|
public TopologyNode(String nodeId, String floorCode, int x, int y,
|
||||||
|
int capacity, boolean safeWait, boolean safeYield) {
|
||||||
|
this.nodeId = requireText(nodeId, "nodeId");
|
||||||
|
this.floorCode = requireText(floorCode, "floorCode");
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.capacity = capacity;
|
||||||
|
this.safeWait = safeWait;
|
||||||
|
this.safeYield = safeYield;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNodeId() {
|
||||||
|
return nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFloorCode() {
|
||||||
|
return floorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getX() {
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getY() {
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCapacity() {
|
||||||
|
return capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSafeWait() {
|
||||||
|
return safeWait;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSafeYield() {
|
||||||
|
return safeYield;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireText(String value, String field) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(field + " must not be blank");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.ruoyi.wcs.port.in;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.application.configuration.ConfigurationPublicationResult;
|
||||||
|
import com.ruoyi.wcs.application.configuration.PublishConfigurationCommand;
|
||||||
|
import com.ruoyi.wcs.application.configuration.PublishedConfigurationView;
|
||||||
|
|
||||||
|
public interface ConfigurationUseCase {
|
||||||
|
void validate(PublishConfigurationCommand command);
|
||||||
|
|
||||||
|
ConfigurationPublicationResult publish(PublishConfigurationCommand command);
|
||||||
|
|
||||||
|
PublishedConfigurationView read(String warehouseCode, long version);
|
||||||
|
|
||||||
|
PublishedConfigurationView readActive(String warehouseCode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.ruoyi.wcs.port.out;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.application.configuration.PublishConfigurationCommand;
|
||||||
|
import com.ruoyi.wcs.application.configuration.PublishedConfigurationView;
|
||||||
|
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
||||||
|
|
||||||
|
public interface ConfigurationRepository {
|
||||||
|
void publish(PublishConfigurationCommand command, EffectiveTopology topology);
|
||||||
|
|
||||||
|
PublishedConfigurationView find(String warehouseCode, long version);
|
||||||
|
|
||||||
|
PublishedConfigurationView findActive(String warehouseCode);
|
||||||
|
|
||||||
|
boolean hasActiveOrderReferences(String warehouseCode, long version);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.ruoyi.wcs.port.out;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
|
||||||
|
|
||||||
|
public interface TopologyDefinitionProvider {
|
||||||
|
TopologyDefinition load(String warehouseCode, long version);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.ruoyi.wcs.application.configuration;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||||
|
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
||||||
|
import com.ruoyi.wcs.domain.topology.ResourceDefinition;
|
||||||
|
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
|
||||||
|
import com.ruoyi.wcs.domain.topology.TopologyEdge;
|
||||||
|
import com.ruoyi.wcs.domain.topology.TopologyNode;
|
||||||
|
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
|
||||||
|
import com.ruoyi.wcs.port.out.ConfigurationRepository;
|
||||||
|
import com.ruoyi.wcs.port.out.TopologyDefinitionProvider;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
class ConfigurationApplicationServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publishesOneImmutableConfigurationVersionAtomically() {
|
||||||
|
InMemoryConfigurationRepository repository = new InMemoryConfigurationRepository();
|
||||||
|
ConfigurationApplicationService service = service(repository,
|
||||||
|
definition(7L));
|
||||||
|
|
||||||
|
ConfigurationPublicationResult result = service.publish(
|
||||||
|
new PublishConfigurationCommand("FT546", 7L));
|
||||||
|
|
||||||
|
assertEquals(7L, result.getPublishedVersion());
|
||||||
|
assertEquals(1, repository.publishCount);
|
||||||
|
assertEquals(0, repository.partialWriteCount);
|
||||||
|
assertEquals(7L, service.readActive("FT546").getTopologyVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsOneInternallyConsistentPublishedVersion() {
|
||||||
|
InMemoryConfigurationRepository repository = new InMemoryConfigurationRepository();
|
||||||
|
ConfigurationApplicationService service = service(repository,
|
||||||
|
definition(7L));
|
||||||
|
service.publish(new PublishConfigurationCommand("FT546", 7L));
|
||||||
|
|
||||||
|
PublishedConfigurationView view = service.read("FT546", 7L);
|
||||||
|
|
||||||
|
assertEquals(7L, view.getTopologyVersion());
|
||||||
|
assertEquals(7L, view.getDeviceConfigurationVersion());
|
||||||
|
assertEquals(7L, view.getWorkflowConfigurationVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refusesToReplaceVersionReferencedByAnActiveOrder() {
|
||||||
|
InMemoryConfigurationRepository repository = new InMemoryConfigurationRepository();
|
||||||
|
Map<Long, TopologyDefinition> definitions = new LinkedHashMap<Long, TopologyDefinition>();
|
||||||
|
definitions.put(7L, definition(7L));
|
||||||
|
definitions.put(8L, definition(8L));
|
||||||
|
ConfigurationApplicationService service = service(repository,
|
||||||
|
(warehouseCode, version) -> definitions.get(version));
|
||||||
|
service.publish(new PublishConfigurationCommand("FT546", 7L));
|
||||||
|
repository.activeReferences = true;
|
||||||
|
|
||||||
|
DomainRuleViolation error = assertThrows(DomainRuleViolation.class,
|
||||||
|
() -> service.publish(new PublishConfigurationCommand("FT546", 8L)));
|
||||||
|
|
||||||
|
assertEquals("active orders reference configuration version 7", error.getMessage());
|
||||||
|
assertEquals(1, repository.publishCount);
|
||||||
|
assertEquals(7L, service.readActive("FT546").getTopologyVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validationNeverWritesRepositoryState() {
|
||||||
|
InMemoryConfigurationRepository repository = new InMemoryConfigurationRepository();
|
||||||
|
ConfigurationApplicationService service = service(repository,
|
||||||
|
definition(7L));
|
||||||
|
|
||||||
|
service.validate(new PublishConfigurationCommand("FT546", 7L));
|
||||||
|
|
||||||
|
assertEquals(0, repository.publishCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConfigurationApplicationService service(
|
||||||
|
InMemoryConfigurationRepository repository,
|
||||||
|
TopologyDefinition definition) {
|
||||||
|
return service(repository, (warehouseCode, version) -> definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConfigurationApplicationService service(
|
||||||
|
InMemoryConfigurationRepository repository,
|
||||||
|
TopologyDefinitionProvider provider) {
|
||||||
|
return new ConfigurationApplicationService(provider,
|
||||||
|
new TopologyCompiler(), repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TopologyDefinition definition(long version) {
|
||||||
|
return new TopologyDefinition("FT546", version, true,
|
||||||
|
Arrays.asList(
|
||||||
|
new TopologyNode("A", "F1", 0, 0, 1, false, false),
|
||||||
|
new TopologyNode("B", "F1", 1, 0, 1, false, false)),
|
||||||
|
Collections.singletonList(new TopologyEdge("A", "B", 10, 1)),
|
||||||
|
Collections.<ResourceDefinition>emptyList(),
|
||||||
|
Collections.<String>emptyList(), Collections.<String>emptyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class InMemoryConfigurationRepository
|
||||||
|
implements ConfigurationRepository {
|
||||||
|
private final Map<Long, PublishedConfigurationView> versions =
|
||||||
|
new LinkedHashMap<Long, PublishedConfigurationView>();
|
||||||
|
private PublishedConfigurationView active;
|
||||||
|
private boolean activeReferences;
|
||||||
|
private int publishCount;
|
||||||
|
private int partialWriteCount;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void publish(PublishConfigurationCommand command,
|
||||||
|
EffectiveTopology topology) {
|
||||||
|
PublishedConfigurationView view = PublishedConfigurationView.from(topology);
|
||||||
|
versions.put(command.getVersion(), view);
|
||||||
|
active = view;
|
||||||
|
publishCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublishedConfigurationView find(String warehouseCode, long version) {
|
||||||
|
return versions.get(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublishedConfigurationView findActive(String warehouseCode) {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasActiveOrderReferences(String warehouseCode, long version) {
|
||||||
|
return activeReferences && active != null
|
||||||
|
&& active.getTopologyVersion() == version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.ruoyi.wcs.domain.topology;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class TopologyCompilerTest {
|
||||||
|
private final TopologyCompiler compiler = new TopologyCompiler();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void canonicalizesUndirectedEdgeResources() {
|
||||||
|
EffectiveTopology topology = compiler.compile(definition(
|
||||||
|
Arrays.asList(node("A", 0, 0), node("B", 1, 0)),
|
||||||
|
Collections.singletonList(new TopologyEdge("B", "A", 10, 1)),
|
||||||
|
Collections.<String>emptyList(), Collections.<String>emptyList()));
|
||||||
|
|
||||||
|
assertEquals("EDGE:A:B", topology.edge("A", "B").getResourceKey());
|
||||||
|
assertEquals("EDGE:A:B", topology.edge("B", "A").getResourceKey());
|
||||||
|
assertEquals(Collections.singletonList("B"), topology.neighbors("A"));
|
||||||
|
assertEquals(1, topology.resource("NODE:A").getCapacity());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDisconnectedLiftPortReference() {
|
||||||
|
TopologyDefinition definition = definition(
|
||||||
|
Arrays.asList(node("A", 0, 0), node("B", 1, 0)),
|
||||||
|
Collections.singletonList(new TopologyEdge("A", "B", 10, 1)),
|
||||||
|
Collections.singletonList("MISSING"), Collections.<String>emptyList());
|
||||||
|
|
||||||
|
DomainRuleViolation error = assertThrows(DomainRuleViolation.class,
|
||||||
|
() -> compiler.compile(definition));
|
||||||
|
assertEquals("lift port references unknown node: MISSING", error.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDuplicateCoordinatesOnTheSameFloor() {
|
||||||
|
TopologyDefinition definition = definition(
|
||||||
|
Arrays.asList(node("A", 0, 0), node("B", 0, 0)),
|
||||||
|
Collections.singletonList(new TopologyEdge("A", "B", 10, 1)),
|
||||||
|
Collections.<String>emptyList(), Collections.<String>emptyList());
|
||||||
|
|
||||||
|
assertThrows(DomainRuleViolation.class, () -> compiler.compile(definition));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnconfirmedConfigurationAndNonPositiveCapacity() {
|
||||||
|
TopologyDefinition unconfirmed = new TopologyDefinition("FT546", 7L, false,
|
||||||
|
Collections.singletonList(node("A", 0, 0)),
|
||||||
|
Collections.<TopologyEdge>emptyList(),
|
||||||
|
Collections.<ResourceDefinition>emptyList(),
|
||||||
|
Collections.<String>emptyList(), Collections.<String>emptyList());
|
||||||
|
TopologyDefinition invalidCapacity = new TopologyDefinition("FT546", 7L, true,
|
||||||
|
Collections.singletonList(node("A", 0, 0)),
|
||||||
|
Collections.<TopologyEdge>emptyList(),
|
||||||
|
Collections.singletonList(new ResourceDefinition("NODE:A", 0)),
|
||||||
|
Collections.<String>emptyList(), Collections.<String>emptyList());
|
||||||
|
|
||||||
|
assertThrows(DomainRuleViolation.class, () -> compiler.compile(unconfirmed));
|
||||||
|
assertThrows(DomainRuleViolation.class, () -> compiler.compile(invalidCapacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compiledTopologyDoesNotChangeWhenDefinitionCollectionsChange() {
|
||||||
|
List<TopologyNode> nodes = new ArrayList<TopologyNode>(
|
||||||
|
Arrays.asList(node("A", 0, 0), node("B", 1, 0)));
|
||||||
|
EffectiveTopology topology = compiler.compile(definition(nodes,
|
||||||
|
Collections.singletonList(new TopologyEdge("A", "B", 10, 1)),
|
||||||
|
Collections.<String>emptyList(), Collections.<String>emptyList()));
|
||||||
|
|
||||||
|
nodes.clear();
|
||||||
|
|
||||||
|
assertTrue(topology.containsNode("A"));
|
||||||
|
assertThrows(UnsupportedOperationException.class,
|
||||||
|
() -> topology.neighbors("A").add("C"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TopologyDefinition definition(List<TopologyNode> nodes,
|
||||||
|
List<TopologyEdge> edges,
|
||||||
|
List<String> liftPorts,
|
||||||
|
List<String> conveyorPorts) {
|
||||||
|
return new TopologyDefinition("FT546", 7L, true, nodes, edges,
|
||||||
|
Collections.<ResourceDefinition>emptyList(), liftPorts, conveyorPorts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TopologyNode node(String id, int x, int y) {
|
||||||
|
return new TopologyNode(id, "F1", x, y, 1, false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user