feat(wcs): add device capabilities and snapshots

This commit is contained in:
zgw
2026-08-10 15:58:58 +08:00
parent 67e47bbdc5
commit ea0520a27a
6 changed files with 291 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
package com.ruoyi.wcs.domain.equipment;
public enum DeviceCapability {
FOUR_WAY_MOVE,
LOAD_HANDLING,
LIFT_TRANSFER,
CONVEYOR_TRANSFER,
CHARGING
}

View File

@@ -0,0 +1,52 @@
package com.ruoyi.wcs.domain.equipment;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;
public final class DeviceDescriptor {
private final String deviceId;
private final DeviceType deviceType;
private final String floorCode;
private final Set<DeviceCapability> capabilities;
public DeviceDescriptor(String deviceId, DeviceType deviceType,
String floorCode, Set<DeviceCapability> capabilities) {
this.deviceId = requireText(deviceId, "deviceId");
this.deviceType = Objects.requireNonNull(deviceType, "deviceType");
this.floorCode = requireText(floorCode, "floorCode");
Objects.requireNonNull(capabilities, "capabilities");
EnumSet<DeviceCapability> copied = capabilities.isEmpty()
? EnumSet.noneOf(DeviceCapability.class)
: EnumSet.copyOf(capabilities);
this.capabilities = Collections.unmodifiableSet(copied);
}
public String getDeviceId() {
return deviceId;
}
public DeviceType getDeviceType() {
return deviceType;
}
public String getFloorCode() {
return floorCode;
}
public Set<DeviceCapability> getCapabilities() {
return capabilities;
}
public boolean supports(DeviceCapability capability) {
return capabilities.contains(Objects.requireNonNull(capability, "capability"));
}
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();
}
}

View File

@@ -0,0 +1,8 @@
package com.ruoyi.wcs.domain.equipment;
public enum DeviceType {
FOUR_WAY_SHUTTLE,
LIFT,
CONVEYOR,
CHARGER
}

View File

@@ -0,0 +1,143 @@
package com.ruoyi.wcs.domain.equipment;
import java.time.Instant;
import java.util.Objects;
public final class VehicleSnapshot {
public enum ConnectionState {
ONLINE,
OFFLINE,
UNKNOWN
}
public enum OperatingMode {
AUTO,
MANUAL
}
public enum HealthState {
NORMAL,
FAULT,
UNKNOWN
}
public enum OccupationState {
IDLE,
BUSY,
CHARGING,
UNKNOWN
}
private final DeviceDescriptor descriptor;
private final ConnectionState connectionState;
private final OperatingMode operatingMode;
private final HealthState healthState;
private final OccupationState occupationState;
private final String locationNodeId;
private final String loadId;
private final int batteryPercent;
private final String currentCommandId;
private final String floorCode;
private final Instant snapshotTime;
public VehicleSnapshot(DeviceDescriptor descriptor,
ConnectionState connectionState,
OperatingMode operatingMode,
HealthState healthState,
OccupationState occupationState,
String locationNodeId,
String loadId,
int batteryPercent,
String currentCommandId,
String floorCode,
Instant snapshotTime) {
this.descriptor = Objects.requireNonNull(descriptor, "descriptor");
this.connectionState = Objects.requireNonNull(connectionState, "connectionState");
this.operatingMode = Objects.requireNonNull(operatingMode, "operatingMode");
this.healthState = Objects.requireNonNull(healthState, "healthState");
this.occupationState = Objects.requireNonNull(occupationState, "occupationState");
this.locationNodeId = requireText(locationNodeId, "locationNodeId");
this.loadId = normalizeOptional(loadId);
if (batteryPercent < 0 || batteryPercent > 100) {
throw new IllegalArgumentException("batteryPercent must be between 0 and 100");
}
this.batteryPercent = batteryPercent;
this.currentCommandId = normalizeOptional(currentCommandId);
this.floorCode = requireText(floorCode, "floorCode");
this.snapshotTime = Objects.requireNonNull(snapshotTime, "snapshotTime");
}
public boolean isDispatchable(int minimumBattery) {
if (minimumBattery < 0 || minimumBattery > 100) {
throw new IllegalArgumentException("minimumBattery must be between 0 and 100");
}
return connectionState == ConnectionState.ONLINE
&& operatingMode == OperatingMode.AUTO
&& healthState == HealthState.NORMAL
&& occupationState == OccupationState.IDLE
&& batteryPercent >= minimumBattery;
}
public boolean supports(DeviceCapability capability) {
return descriptor.supports(capability);
}
public String getVehicleId() {
return descriptor.getDeviceId();
}
public DeviceDescriptor getDescriptor() {
return descriptor;
}
public ConnectionState getConnectionState() {
return connectionState;
}
public OperatingMode getOperatingMode() {
return operatingMode;
}
public HealthState getHealthState() {
return healthState;
}
public OccupationState getOccupationState() {
return occupationState;
}
public String getLocationNodeId() {
return locationNodeId;
}
public String getLoadId() {
return loadId;
}
public int getBatteryPercent() {
return batteryPercent;
}
public String getCurrentCommandId() {
return currentCommandId;
}
public String getFloorCode() {
return floorCode;
}
public Instant getSnapshotTime() {
return snapshotTime;
}
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();
}
private static String normalizeOptional(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim();
}
}

View File

@@ -0,0 +1,9 @@
package com.ruoyi.wcs.port.out;
import com.ruoyi.wcs.domain.equipment.DeviceDescriptor;
import java.util.List;
public interface DeviceRegistry {
List<DeviceDescriptor> vehicles();
}

View File

@@ -0,0 +1,70 @@
package com.ruoyi.wcs.domain.equipment;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.time.Instant;
import java.util.EnumSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DeviceCapabilityTest {
@ParameterizedTest
@CsvSource({
"ONLINE,AUTO,NORMAL,IDLE,80,true",
"OFFLINE,AUTO,NORMAL,IDLE,80,false",
"ONLINE,MANUAL,NORMAL,IDLE,80,false",
"ONLINE,AUTO,FAULT,IDLE,80,false",
"ONLINE,AUTO,NORMAL,BUSY,80,false",
"ONLINE,AUTO,NORMAL,IDLE,20,false",
"ONLINE,AUTO,NORMAL,IDLE,30,true"
})
void dispatchabilityUsesOrthogonalStateDimensions(
String connection, String mode, String health,
String occupation, int battery, boolean expected) {
VehicleSnapshot snapshot = snapshot(connection, mode, health,
occupation, battery);
assertEquals(expected, snapshot.isDispatchable(30));
}
@Test
void descriptorMatchesOnlyDeclaredCapabilities() {
DeviceDescriptor descriptor = descriptor();
assertTrue(descriptor.supports(DeviceCapability.FOUR_WAY_MOVE));
assertTrue(descriptor.supports(DeviceCapability.LOAD_HANDLING));
assertFalse(descriptor.supports(DeviceCapability.LIFT_TRANSFER));
}
@Test
void descriptorCapabilitySetCannotBeMutated() {
DeviceDescriptor descriptor = descriptor();
assertThrows(UnsupportedOperationException.class,
() -> descriptor.getCapabilities().add(DeviceCapability.LIFT_TRANSFER));
}
private static VehicleSnapshot snapshot(String connection, String mode,
String health, String occupation,
int battery) {
return new VehicleSnapshot(descriptor(),
VehicleSnapshot.ConnectionState.valueOf(connection),
VehicleSnapshot.OperatingMode.valueOf(mode),
VehicleSnapshot.HealthState.valueOf(health),
VehicleSnapshot.OccupationState.valueOf(occupation),
"A", null, battery, null, "F1",
Instant.parse("2026-08-04T00:00:00Z"));
}
private static DeviceDescriptor descriptor() {
return new DeviceDescriptor("V1", DeviceType.FOUR_WAY_SHUTTLE, "F1",
EnumSet.of(DeviceCapability.FOUR_WAY_MOVE,
DeviceCapability.LOAD_HANDLING));
}
}