From 67e47bbdc59b7ee321e7799ee4e303ee812a8601 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Mon, 10 Aug 2026 15:55:59 +0800 Subject: [PATCH] feat(wcs): publish immutable warehouse configuration --- .../ConfigurationApplicationService.java | 88 +++++++++ .../ConfigurationPublicationResult.java | 19 ++ .../PublishConfigurationCommand.java | 22 +++ .../PublishedConfigurationView.java | 52 ++++++ .../domain/topology/EffectiveTopology.java | 71 +++++++ .../domain/topology/ResourceDefinition.java | 22 +++ .../wcs/domain/topology/TopologyCompiler.java | 175 ++++++++++++++++++ .../domain/topology/TopologyDefinition.java | 74 ++++++++ .../wcs/domain/topology/TopologyEdge.java | 63 +++++++ .../wcs/domain/topology/TopologyNode.java | 57 ++++++ .../wcs/port/in/ConfigurationUseCase.java | 15 ++ .../wcs/port/out/ConfigurationRepository.java | 15 ++ .../port/out/TopologyDefinitionProvider.java | 7 + .../ConfigurationApplicationServiceTest.java | 140 ++++++++++++++ .../domain/topology/TopologyCompilerTest.java | 96 ++++++++++ 15 files changed, 916 insertions(+) create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/ConfigurationApplicationService.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/ConfigurationPublicationResult.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/PublishConfigurationCommand.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/PublishedConfigurationView.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/EffectiveTopology.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/ResourceDefinition.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyCompiler.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyDefinition.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyEdge.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyNode.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/in/ConfigurationUseCase.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/ConfigurationRepository.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TopologyDefinitionProvider.java create mode 100644 ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/configuration/ConfigurationApplicationServiceTest.java create mode 100644 ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/topology/TopologyCompilerTest.java diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/ConfigurationApplicationService.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/ConfigurationApplicationService.java new file mode 100644 index 0000000..41ef2c1 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/ConfigurationApplicationService.java @@ -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(); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/ConfigurationPublicationResult.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/ConfigurationPublicationResult.java new file mode 100644 index 0000000..f36aae5 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/ConfigurationPublicationResult.java @@ -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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/PublishConfigurationCommand.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/PublishConfigurationCommand.java new file mode 100644 index 0000000..6c078c7 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/PublishConfigurationCommand.java @@ -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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/PublishedConfigurationView.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/PublishedConfigurationView.java new file mode 100644 index 0000000..3c559d3 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/configuration/PublishedConfigurationView.java @@ -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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/EffectiveTopology.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/EffectiveTopology.java new file mode 100644 index 0000000..fb8bab4 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/EffectiveTopology.java @@ -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 nodes; + private final Map edges; + private final Map resources; + private final Map> neighbors; + + EffectiveTopology(String warehouseCode, long version, + Map nodes, + Map edges, + Map resources, + Map> neighbors) { + this.warehouseCode = warehouseCode; + this.version = version; + this.nodes = Collections.unmodifiableMap( + new LinkedHashMap(nodes)); + this.edges = Collections.unmodifiableMap( + new LinkedHashMap(edges)); + this.resources = Collections.unmodifiableMap( + new LinkedHashMap(resources)); + Map> copiedNeighbors = + new LinkedHashMap>(); + for (Map.Entry> entry : neighbors.entrySet()) { + copiedNeighbors.put(entry.getKey(), Collections.unmodifiableList( + new ArrayList(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 neighbors(String nodeId) { + List result = neighbors.get(nodeId); + return result == null ? Collections.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 getResources() { + return resources; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/ResourceDefinition.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/ResourceDefinition.java new file mode 100644 index 0000000..a20f1aa --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/ResourceDefinition.java @@ -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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyCompiler.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyCompiler.java new file mode 100644 index 0000000..284adc0 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyCompiler.java @@ -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 nodes = uniqueNodes(definition.getNodes()); + Map> neighbors = emptyNeighbors(nodes); + Map resources = nodeResources(nodes); + Map 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 adjacent : neighbors.values()) { + Collections.sort(adjacent); + } + return new EffectiveTopology(definition.getWarehouseCode(), + definition.getVersion(), nodes, edges, resources, neighbors); + } + + private static Map uniqueNodes(List definitions) { + Map nodes = new LinkedHashMap(); + Set coordinates = new HashSet(); + 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> emptyNeighbors( + Map nodes) { + Map> neighbors = + new LinkedHashMap>(); + for (String nodeId : nodes.keySet()) { + neighbors.put(nodeId, new ArrayList()); + } + return neighbors; + } + + private static Map nodeResources( + Map nodes) { + Map resources = + new LinkedHashMap(); + for (TopologyNode node : nodes.values()) { + ResourceDefinition resource = new ResourceDefinition( + "NODE:" + node.getNodeId(), node.getCapacity()); + resources.put(resource.getResourceKey(), resource); + } + return resources; + } + + private static Map compileEdges( + List definitions, + Map nodes, + Map> neighbors, + Map resources) { + Map edges = new LinkedHashMap(); + 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 portNodeIds, + Map nodes) { + Set seen = new HashSet(); + 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 nodes, + Map> 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 definitions, + Map resources) { + Set explicitKeys = new HashSet(); + 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); + } + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyDefinition.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyDefinition.java new file mode 100644 index 0000000..4cbef4f --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyDefinition.java @@ -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 nodes; + private final List edges; + private final List resources; + private final List liftPortNodeIds; + private final List conveyorPortNodeIds; + + public TopologyDefinition(String warehouseCode, long version, boolean confirmed, + List nodes, List edges, + List resources, + List liftPortNodeIds, + List 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 getNodes() { + return nodes; + } + + public List getEdges() { + return edges; + } + + public List getResources() { + return resources; + } + + public List getLiftPortNodeIds() { + return liftPortNodeIds; + } + + public List getConveyorPortNodeIds() { + return conveyorPortNodeIds; + } + + private static List immutable(List source, String field) { + if (source == null) { + throw new IllegalArgumentException(field + " must not be null"); + } + return Collections.unmodifiableList(new ArrayList(source)); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyEdge.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyEdge.java new file mode 100644 index 0000000..245bda3 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyEdge.java @@ -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(); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyNode.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyNode.java new file mode 100644 index 0000000..ee607b6 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/topology/TopologyNode.java @@ -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(); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/in/ConfigurationUseCase.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/in/ConfigurationUseCase.java new file mode 100644 index 0000000..d41c415 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/in/ConfigurationUseCase.java @@ -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); +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/ConfigurationRepository.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/ConfigurationRepository.java new file mode 100644 index 0000000..484bdb9 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/ConfigurationRepository.java @@ -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); +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TopologyDefinitionProvider.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TopologyDefinitionProvider.java new file mode 100644 index 0000000..bd86473 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TopologyDefinitionProvider.java @@ -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); +} diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/configuration/ConfigurationApplicationServiceTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/configuration/ConfigurationApplicationServiceTest.java new file mode 100644 index 0000000..d1bc6a1 --- /dev/null +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/configuration/ConfigurationApplicationServiceTest.java @@ -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 definitions = new LinkedHashMap(); + 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.emptyList(), + Collections.emptyList(), Collections.emptyList()); + } + + private static final class InMemoryConfigurationRepository + implements ConfigurationRepository { + private final Map versions = + new LinkedHashMap(); + 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; + } + } +} diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/topology/TopologyCompilerTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/topology/TopologyCompilerTest.java new file mode 100644 index 0000000..a813e93 --- /dev/null +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/topology/TopologyCompilerTest.java @@ -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.emptyList(), Collections.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.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.emptyList(), Collections.emptyList()); + + assertThrows(DomainRuleViolation.class, () -> compiler.compile(definition)); + } + + @Test + void rejectsUnconfirmedConfigurationAndNonPositiveCapacity() { + TopologyDefinition unconfirmed = new TopologyDefinition("FT546", 7L, false, + Collections.singletonList(node("A", 0, 0)), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); + TopologyDefinition invalidCapacity = new TopologyDefinition("FT546", 7L, true, + Collections.singletonList(node("A", 0, 0)), + Collections.emptyList(), + Collections.singletonList(new ResourceDefinition("NODE:A", 0)), + Collections.emptyList(), Collections.emptyList()); + + assertThrows(DomainRuleViolation.class, () -> compiler.compile(unconfirmed)); + assertThrows(DomainRuleViolation.class, () -> compiler.compile(invalidCapacity)); + } + + @Test + void compiledTopologyDoesNotChangeWhenDefinitionCollectionsChange() { + List nodes = new ArrayList( + 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.emptyList(), Collections.emptyList())); + + nodes.clear(); + + assertTrue(topology.containsNode("A")); + assertThrows(UnsupportedOperationException.class, + () -> topology.neighbors("A").add("C")); + } + + private static TopologyDefinition definition(List nodes, + List edges, + List liftPorts, + List conveyorPorts) { + return new TopologyDefinition("FT546", 7L, true, nodes, edges, + Collections.emptyList(), liftPorts, conveyorPorts); + } + + private static TopologyNode node(String id, int x, int y) { + return new TopologyNode(id, "F1", x, y, 1, false, false); + } +}