feat: prepare warehouse 01 floor 1 inbound pilot
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
package com.ruoyi.algorithm.pilot;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "fortucky.inbound-pilot")
|
||||
public class Layer1InboundPilotProperties {
|
||||
private boolean enabled;
|
||||
private String warehouseId = "01";
|
||||
private int floor = 1;
|
||||
private long planningTimeoutMillis = 1500L;
|
||||
private int snapshotMaxAgeSeconds = 60;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getWarehouseId() {
|
||||
return warehouseId;
|
||||
}
|
||||
|
||||
public void setWarehouseId(String warehouseId) {
|
||||
this.warehouseId = warehouseId == null ? "" : warehouseId.trim();
|
||||
}
|
||||
|
||||
public int getFloor() {
|
||||
return floor;
|
||||
}
|
||||
|
||||
public void setFloor(int floor) {
|
||||
this.floor = floor;
|
||||
}
|
||||
|
||||
public long getPlanningTimeoutMillis() {
|
||||
return planningTimeoutMillis;
|
||||
}
|
||||
|
||||
public void setPlanningTimeoutMillis(long planningTimeoutMillis) {
|
||||
this.planningTimeoutMillis = planningTimeoutMillis;
|
||||
}
|
||||
|
||||
public int getSnapshotMaxAgeSeconds() {
|
||||
return snapshotMaxAgeSeconds;
|
||||
}
|
||||
|
||||
public void setSnapshotMaxAgeSeconds(int snapshotMaxAgeSeconds) {
|
||||
this.snapshotMaxAgeSeconds = snapshotMaxAgeSeconds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ruoyi.algorithm.pilot;
|
||||
|
||||
public record Layer1InboundPilotResult(Status status, String finalSpaceId, String fallbackReason) {
|
||||
public enum Status {
|
||||
NOT_ELIGIBLE,
|
||||
COMMITTED,
|
||||
FALLBACK,
|
||||
INDETERMINATE
|
||||
}
|
||||
|
||||
public static Layer1InboundPilotResult notEligible() {
|
||||
return new Layer1InboundPilotResult(Status.NOT_ELIGIBLE, null, null);
|
||||
}
|
||||
|
||||
public static Layer1InboundPilotResult committed(String finalSpaceId) {
|
||||
return new Layer1InboundPilotResult(Status.COMMITTED, finalSpaceId, null);
|
||||
}
|
||||
|
||||
public static Layer1InboundPilotResult fallback(String reason) {
|
||||
return new Layer1InboundPilotResult(Status.FALLBACK, null, reason);
|
||||
}
|
||||
|
||||
public static Layer1InboundPilotResult indeterminate(String reason) {
|
||||
return new Layer1InboundPilotResult(Status.INDETERMINATE, null, reason);
|
||||
}
|
||||
|
||||
public boolean committed() {
|
||||
return status == Status.COMMITTED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package com.ruoyi.algorithm.pilot;
|
||||
|
||||
import com.ruoyi.algorithm.shadow.InboundShadowObservation;
|
||||
import com.ruoyi.algorithm.shadow.PublishedShadowSnapshot;
|
||||
import com.ruoyi.algorithm.shadow.ShadowPlanningEngine;
|
||||
import com.ruoyi.algorithm.shadow.ShadowSnapshotDescriptor;
|
||||
import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper;
|
||||
import com.ruoyi.api.domain.RequestEnter;
|
||||
import com.ruoyi.job.mydojob.domain.MydoJob;
|
||||
import com.ruoyi.job.mydojob.mapper.MydoJobMapper;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
@Component
|
||||
public class Layer1InboundPilotService implements AutoCloseable {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(Layer1InboundPilotService.class);
|
||||
private static final String PUBLISHED = "PUBLISHED";
|
||||
private static final String PILOT_WAREHOUSE_ID = "01";
|
||||
private static final int PILOT_FLOOR = 1;
|
||||
private static final int REASON_LIMIT = 3900;
|
||||
|
||||
private final Layer1InboundPilotProperties properties;
|
||||
private final ShadowInputMapper shadowInputMapper;
|
||||
private final ShadowPlanningEngine planningEngine;
|
||||
private final MydoJobMapper jobMapper;
|
||||
private final Clock clock;
|
||||
private final ExecutorService planningExecutor;
|
||||
|
||||
@Autowired
|
||||
public Layer1InboundPilotService(Layer1InboundPilotProperties properties,
|
||||
ShadowInputMapper shadowInputMapper, ShadowPlanningEngine planningEngine,
|
||||
MydoJobMapper jobMapper) {
|
||||
this(properties, shadowInputMapper, planningEngine, jobMapper, Clock.systemUTC(), newPlanningExecutor());
|
||||
}
|
||||
|
||||
Layer1InboundPilotService(Layer1InboundPilotProperties properties,
|
||||
ShadowInputMapper shadowInputMapper, ShadowPlanningEngine planningEngine,
|
||||
MydoJobMapper jobMapper, Clock clock, ExecutorService planningExecutor) {
|
||||
this.properties = Objects.requireNonNull(properties, "properties");
|
||||
this.shadowInputMapper = Objects.requireNonNull(shadowInputMapper, "shadowInputMapper");
|
||||
this.planningEngine = Objects.requireNonNull(planningEngine, "planningEngine");
|
||||
this.jobMapper = Objects.requireNonNull(jobMapper, "jobMapper");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.planningExecutor = Objects.requireNonNull(planningExecutor, "planningExecutor");
|
||||
}
|
||||
|
||||
public Layer1InboundPilotResult attempt(RequestEnter request, MydoJob job) {
|
||||
if (!eligible(request, job)) {
|
||||
return Layer1InboundPilotResult.notEligible();
|
||||
}
|
||||
|
||||
long startedNanos = System.nanoTime();
|
||||
long generation = 0L;
|
||||
String proposedSpace = null;
|
||||
Layer1InboundPilotResult result;
|
||||
try {
|
||||
Proposal proposal = planWithTimeout(request, job);
|
||||
generation = proposal.generation();
|
||||
proposedSpace = proposal.proposedSpaceId();
|
||||
if (proposal.fallbackReason() != null) {
|
||||
result = Layer1InboundPilotResult.fallback(proposal.fallbackReason());
|
||||
} else {
|
||||
result = mutate(request, job, proposal);
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
result = Layer1InboundPilotResult.fallback("PLAN_INTERRUPTED");
|
||||
} catch (TimeoutException timeout) {
|
||||
result = Layer1InboundPilotResult.fallback("PLAN_TIMEOUT");
|
||||
} catch (RejectedExecutionException busy) {
|
||||
result = Layer1InboundPilotResult.fallback("PLAN_BUSY");
|
||||
} catch (ExecutionException planningFailure) {
|
||||
result = Layer1InboundPilotResult.fallback("PLAN_ERROR_" + simpleName(planningFailure.getCause()));
|
||||
} catch (RuntimeException | Error unexpected) {
|
||||
result = Layer1InboundPilotResult.fallback("PILOT_ERROR_" + simpleName(unexpected));
|
||||
}
|
||||
|
||||
audit(request, job, generation, proposedSpace, result, elapsedMillis(startedNanos));
|
||||
return result;
|
||||
}
|
||||
|
||||
private Proposal planWithTimeout(RequestEnter request, MydoJob job)
|
||||
throws InterruptedException, ExecutionException, TimeoutException {
|
||||
long timeout = properties.getPlanningTimeoutMillis();
|
||||
if (timeout < 1L || properties.getSnapshotMaxAgeSeconds() < 5
|
||||
|| properties.getSnapshotMaxAgeSeconds() > 300) {
|
||||
return Proposal.fallback("INVALID_PILOT_CONFIGURATION");
|
||||
}
|
||||
Future<Proposal> future = planningExecutor.submit(() -> plan(request, job));
|
||||
try {
|
||||
return future.get(timeout, TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException timeoutException) {
|
||||
future.cancel(true);
|
||||
throw timeoutException;
|
||||
}
|
||||
}
|
||||
|
||||
private Proposal plan(RequestEnter request, MydoJob job) {
|
||||
String warehouseId = PILOT_WAREHOUSE_ID;
|
||||
ShadowSnapshotDescriptor descriptor = shadowInputMapper.selectPublishedSnapshot(warehouseId);
|
||||
String invalid = invalidSnapshot(descriptor, warehouseId);
|
||||
if (invalid != null) {
|
||||
return Proposal.fallback(invalid);
|
||||
}
|
||||
PublishedShadowSnapshot snapshot = new PublishedShadowSnapshot(
|
||||
descriptor.getSnapshotGeneration(), descriptor.getWarehouseId(),
|
||||
descriptor.getSynchronizedAt(), descriptor.getSourceSignature());
|
||||
InboundShadowObservation observation = planningEngine.planInbound(snapshot,
|
||||
request.getTaskNo(), job.getTrayCode(), PILOT_FLOOR);
|
||||
if (!observation.observed() || isBlank(observation.proposedSpaceId())
|
||||
|| !("READY".equals(observation.resultCode())
|
||||
|| "READY_CONSERVATIVE".equals(observation.resultCode()))) {
|
||||
return Proposal.fallback(observation.resultCode() == null
|
||||
? "PLAN_NOT_READY" : "PLAN_" + observation.resultCode());
|
||||
}
|
||||
return new Proposal(observation.snapshotGeneration(), observation.proposedSpaceId(),
|
||||
observation.reason(), null);
|
||||
}
|
||||
|
||||
private String invalidSnapshot(ShadowSnapshotDescriptor descriptor, String warehouseId) {
|
||||
if (descriptor == null || !PUBLISHED.equals(descriptor.getSnapshotStatus())
|
||||
|| descriptor.getSnapshotGeneration() <= 0L
|
||||
|| descriptor.getSynchronizedAt() == null || isBlank(descriptor.getSourceSignature())) {
|
||||
return "SNAPSHOT_NOT_READY";
|
||||
}
|
||||
if (!warehouseEquivalent(descriptor.getWarehouseId(), warehouseId)) {
|
||||
return "SNAPSHOT_WAREHOUSE_MISMATCH";
|
||||
}
|
||||
Duration age = Duration.between(descriptor.getSynchronizedAt(), clock.instant());
|
||||
if (age.isNegative()) {
|
||||
return "SNAPSHOT_TIME_INVALID";
|
||||
}
|
||||
if (age.compareTo(Duration.ofSeconds(properties.getSnapshotMaxAgeSeconds())) > 0) {
|
||||
return "SNAPSHOT_STALE";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Layer1InboundPilotResult mutate(RequestEnter request, MydoJob job, Proposal proposal) {
|
||||
HashMap<String, Object> arguments = new HashMap<>();
|
||||
arguments.put("v_task_no", request.getTaskNo());
|
||||
arguments.put("v_tray_code", job.getTrayCode());
|
||||
arguments.put("v_code", request.getDeviceCode());
|
||||
arguments.put("v_space_id", proposal.proposedSpaceId());
|
||||
arguments.put("v_warehouse_id", PILOT_WAREHOUSE_ID);
|
||||
arguments.put("v_floor", PILOT_FLOOR);
|
||||
arguments.put("v_snapshot_generation", proposal.generation());
|
||||
arguments.put("v_snapshot_max_age_seconds", properties.getSnapshotMaxAgeSeconds());
|
||||
try {
|
||||
jobMapper.allocateLayer1InboundPilot(arguments);
|
||||
int code = intValue(arguments.get("v_result"), -1);
|
||||
String finalSpace = stringValue(arguments.get("v_final_space_id"));
|
||||
if ((code == 10 || code == 11 || code == 12) && !isBlank(finalSpace)) {
|
||||
return Layer1InboundPilotResult.committed(finalSpace);
|
||||
}
|
||||
return Layer1InboundPilotResult.fallback("MUTATION_" + code + "_"
|
||||
+ abbreviate(stringValue(arguments.get("v_resultmsg")), 160));
|
||||
} catch (RuntimeException | Error mutationFailure) {
|
||||
return resolveMutationFailure(request.getTaskNo(), mutationFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private Layer1InboundPilotResult resolveMutationFailure(String taskNo, Throwable mutationFailure) {
|
||||
try {
|
||||
MydoJob current = jobMapper.selectInJobByTaskNo(taskNo);
|
||||
if (current != null && !isBlank(current.getCargoSpaceId())) {
|
||||
return Layer1InboundPilotResult.committed(current.getCargoSpaceId());
|
||||
}
|
||||
return Layer1InboundPilotResult.fallback("MUTATION_ROLLED_BACK_" + simpleName(mutationFailure));
|
||||
} catch (RuntimeException | Error stateReadFailure) {
|
||||
LOG.error("layer1_inbound_pilot_indeterminate task={} mutationError={} stateReadError={}",
|
||||
taskNo, simpleName(mutationFailure), simpleName(stateReadFailure));
|
||||
return Layer1InboundPilotResult.indeterminate("MUTATION_STATE_UNKNOWN");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean eligible(RequestEnter request, MydoJob job) {
|
||||
return properties.isEnabled() && request != null && job != null
|
||||
&& properties.getFloor() == PILOT_FLOOR
|
||||
&& warehouseEquivalent(properties.getWarehouseId(), PILOT_WAREHOUSE_ID)
|
||||
&& Integer.toString(PILOT_FLOOR).equals(trim(request.getFloor()))
|
||||
&& warehouseEquivalent(job.getWarehouseId(), PILOT_WAREHOUSE_ID)
|
||||
&& !isBlank(request.getTaskNo()) && !isBlank(job.getTrayCode())
|
||||
&& job.getCargoSpaceId() == null;
|
||||
}
|
||||
|
||||
private void audit(RequestEnter request, MydoJob job, long generation, String proposedSpace,
|
||||
Layer1InboundPilotResult result, long elapsedMillis) {
|
||||
String reason = "task=" + safe(request.getTaskNo())
|
||||
+ "; tray=" + safe(job.getTrayCode())
|
||||
+ "; proposedSpace=" + safe(proposedSpace)
|
||||
+ "; finalSpace=" + safe(result.finalSpaceId())
|
||||
+ "; elapsedMs=" + elapsedMillis
|
||||
+ "; outcome=" + result.status()
|
||||
+ "; fallbackReason=" + safe(result.fallbackReason());
|
||||
try {
|
||||
shadowInputMapper.insertPilotAudit(UUID.randomUUID().toString(), PILOT_WAREHOUSE_ID,
|
||||
generation, result.status().name(), abbreviate(reason, REASON_LIMIT));
|
||||
} catch (RuntimeException | Error auditFailure) {
|
||||
LOG.warn("layer1_inbound_pilot_audit_failed task={} error={}",
|
||||
request.getTaskNo(), simpleName(auditFailure));
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
@Override
|
||||
public void close() {
|
||||
planningExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
private static ExecutorService newPlanningExecutor() {
|
||||
return new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(1), runnable -> {
|
||||
Thread thread = new Thread(runnable, "layer1-inbound-pilot-planner");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}, new ThreadPoolExecutor.AbortPolicy());
|
||||
}
|
||||
|
||||
private static boolean warehouseEquivalent(String left, String right) {
|
||||
String normalizedLeft = normalizeWarehouse(left);
|
||||
String normalizedRight = normalizeWarehouse(right);
|
||||
return normalizedLeft != null && normalizedLeft.equals(normalizedRight);
|
||||
}
|
||||
|
||||
private static String normalizeWarehouse(String value) {
|
||||
String trimmed = trim(value);
|
||||
if (trimmed == null || trimmed.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed.chars().allMatch(Character::isDigit)) {
|
||||
String normalized = trimmed.replaceFirst("^0+(?!$)", "");
|
||||
return normalized.isEmpty() ? "0" : normalized;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static int intValue(Object value, int fallback) {
|
||||
if (value instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(Objects.toString(value, ""));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static String stringValue(Object value) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
private static String simpleName(Throwable failure) {
|
||||
return failure == null ? "UNKNOWN" : failure.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
private static long elapsedMillis(long startedNanos) {
|
||||
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
return value == null ? "" : abbreviate(value.replace(';', '_'), 255);
|
||||
}
|
||||
|
||||
private static String abbreviate(String value, int limit) {
|
||||
if (value == null || value.length() <= limit) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, limit);
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
return value == null ? null : value.trim();
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private record Proposal(long generation, String proposedSpaceId, String reason, String fallbackReason) {
|
||||
private static Proposal fallback(String reason) {
|
||||
return new Proposal(0L, null, null, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,16 @@ public class ShadowPlanningEngine {
|
||||
public InboundShadowObservation planInbound(PublishedShadowSnapshot snapshot,
|
||||
String taskNo, String trayNo) {
|
||||
return planWithStableSnapshot(snapshot,
|
||||
() -> planInboundInsideTransaction(snapshot, taskNo, trayNo));
|
||||
() -> planInboundInsideTransaction(snapshot, taskNo, trayNo, null));
|
||||
}
|
||||
|
||||
public InboundShadowObservation planInbound(PublishedShadowSnapshot snapshot,
|
||||
String taskNo, String trayNo, Integer targetFloor) {
|
||||
if (targetFloor == null || targetFloor <= 0) {
|
||||
throw new IllegalArgumentException("targetFloor must be positive");
|
||||
}
|
||||
return planWithStableSnapshot(snapshot,
|
||||
() -> planInboundInsideTransaction(snapshot, taskNo, trayNo, targetFloor));
|
||||
}
|
||||
|
||||
public OutboundShadowObservation planProjectOutbound(PublishedShadowSnapshot snapshot,
|
||||
@@ -245,7 +254,7 @@ public class ShadowPlanningEngine {
|
||||
}
|
||||
|
||||
private InboundShadowObservation planInboundInsideTransaction(PublishedShadowSnapshot snapshot,
|
||||
String taskNo, String trayNo) {
|
||||
String taskNo, String trayNo, Integer targetFloor) {
|
||||
String warehouseId = snapshot.warehouseId();
|
||||
ShadowInboundReference reference = inputMapper.selectInboundInput(
|
||||
warehouseId, snapshot.generation(), taskNo, trayNo);
|
||||
@@ -294,7 +303,7 @@ public class ShadowPlanningEngine {
|
||||
InboundRequest request = new InboundRequest(reference.getRequestId(), reference.getTrayNo(),
|
||||
reference.getProjectNo(), multipleProducts ? null : reference.getProductId(),
|
||||
multipleProducts ? null : reference.getLotNo(),
|
||||
null, null, null, Set.of(), null, Set.copyOf(eligibleSpaces));
|
||||
null, null, targetFloor, Set.of(), null, Set.copyOf(eligibleSpaces));
|
||||
InboundPlanningResult result = schedulingFacade.planInboundFromPublishedSnapshot(warehouseId, request);
|
||||
InboundPlan plan = result.plan();
|
||||
if (plan.assignment().isPresent()) {
|
||||
|
||||
@@ -50,6 +50,12 @@ public interface ShadowInputMapper {
|
||||
@Param("resultCode") String resultCode,
|
||||
@Param("reason") String reason);
|
||||
|
||||
int insertPilotAudit(@Param("decisionId") String decisionId,
|
||||
@Param("warehouseId") String warehouseId,
|
||||
@Param("inputVersion") long inputVersion,
|
||||
@Param("resultCode") String resultCode,
|
||||
@Param("reason") String reason);
|
||||
|
||||
void upsertRuntimeMetric(@Param("warehouseId") String warehouseId,
|
||||
@Param("metricName") String metricName,
|
||||
@Param("bucketUpperMicros") long bucketUpperMicros,
|
||||
|
||||
@@ -8,6 +8,8 @@ import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.ruoyi.algorithm.shadow.InboundShadowCompletion;
|
||||
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
|
||||
import com.ruoyi.algorithm.pilot.Layer1InboundPilotResult;
|
||||
import com.ruoyi.algorithm.pilot.Layer1InboundPilotService;
|
||||
import com.ruoyi.api.domain.*;
|
||||
import com.ruoyi.api.service.IToErpService;
|
||||
import com.ruoyi.api.service.IToWmsService;
|
||||
@@ -94,6 +96,8 @@ public class ToWmsServiceImpl implements IToWmsService {
|
||||
private MydoJobMapper mydoJobMapper;
|
||||
@Autowired
|
||||
private ShadowObservationGateway shadowObservationGateway;
|
||||
@Autowired
|
||||
private Layer1InboundPilotService layer1InboundPilotService;
|
||||
|
||||
|
||||
@Override
|
||||
@@ -411,6 +415,22 @@ public class ToWmsServiceImpl implements IToWmsService {
|
||||
if(inJob.getCargoSpaceId() != null && !"".equals(inJob.getCargoSpaceId())){
|
||||
return AjaxResult.success(inJob);
|
||||
}else{
|
||||
Layer1InboundPilotResult pilotResult;
|
||||
try {
|
||||
pilotResult = layer1InboundPilotService.attempt(requestEnter, inJob);
|
||||
} catch (RuntimeException | Error pilotFailure) {
|
||||
pilotResult = Layer1InboundPilotResult.fallback("PILOT_BOUNDARY_FAILURE");
|
||||
}
|
||||
if (pilotResult.committed()) {
|
||||
MydoJob pilotJob = mydoJobMapper.selectInJobByTaskNo(taskNo);
|
||||
if (pilotJob == null) {
|
||||
return AjaxResult.error("一层入库试点已提交但任务重读失败,请重新扫码确认任务货位");
|
||||
}
|
||||
return AjaxResult.success(pilotJob);
|
||||
}
|
||||
if (pilotResult.status() == Layer1InboundPilotResult.Status.INDETERMINATE) {
|
||||
return AjaxResult.error("一层入库试点写入状态待确认,请重新扫码确认任务货位");
|
||||
}
|
||||
//分配货位
|
||||
HashMap<String, Object> map = new HashMap<>();
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ public interface MydoJobMapper
|
||||
|
||||
void allotInJobSpace(HashMap<String, Object> map);
|
||||
|
||||
void allocateLayer1InboundPilot(HashMap<String, Object> map);
|
||||
|
||||
void ykCompleted(HashMap<String, Object> map);
|
||||
|
||||
void updateLed(HashMap<String, Object> map);
|
||||
|
||||
@@ -197,3 +197,9 @@ fortucky:
|
||||
warehouse-id: ${FORTUCKY_ALGORITHM_WAREHOUSE_ID:01}
|
||||
database-writes-enabled: ${FORTUCKY_ALGORITHM_DATABASE_WRITES_ENABLED:true}
|
||||
legacy-fallback-on-technical-error: ${FORTUCKY_ALGORITHM_LEGACY_FALLBACK_ON_TECHNICAL_ERROR:false}
|
||||
inbound-pilot:
|
||||
enabled: ${FORTUCKY_INBOUND_PILOT_ENABLED:false}
|
||||
warehouse-id: ${FORTUCKY_INBOUND_PILOT_WAREHOUSE_ID:01}
|
||||
floor: ${FORTUCKY_INBOUND_PILOT_FLOOR:1}
|
||||
planning-timeout-millis: ${FORTUCKY_INBOUND_PILOT_PLANNING_TIMEOUT_MILLIS:1500}
|
||||
snapshot-max-age-seconds: ${FORTUCKY_INBOUND_PILOT_SNAPSHOT_MAX_AGE_SECONDS:60}
|
||||
|
||||
@@ -232,6 +232,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
and convert(varchar(64), state.source_signature, 2) = #{sourceSignature}
|
||||
</insert>
|
||||
|
||||
<insert id="insertPilotAudit">
|
||||
insert into alg.decision_log (
|
||||
decision_id, warehouse_id, decision_type, input_version, result_code, reason)
|
||||
values (try_convert(uniqueidentifier, #{decisionId}), #{warehouseId}, 'INBOUND_PILOT',
|
||||
convert(binary(8), #{inputVersion}), #{resultCode}, #{reason})
|
||||
</insert>
|
||||
|
||||
<update id="upsertRuntimeMetric">
|
||||
merge alg.shadow_runtime_metric with (holdlock) as target
|
||||
using (select #{warehouseId} as warehouse_id,
|
||||
|
||||
@@ -308,6 +308,22 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
)}
|
||||
</select>
|
||||
|
||||
<select id="allocateLayer1InboundPilot" parameterType="java.util.Map" statementType="CALLABLE" timeout="3">
|
||||
{call proc_allot_layer1_inbound_pilot(
|
||||
#{v_task_no,jdbcType=VARCHAR,mode=IN},
|
||||
#{v_tray_code,jdbcType=VARCHAR,mode=IN},
|
||||
#{v_code,jdbcType=VARCHAR,mode=IN},
|
||||
#{v_space_id,jdbcType=VARCHAR,mode=IN},
|
||||
#{v_warehouse_id,jdbcType=VARCHAR,mode=IN},
|
||||
#{v_floor,jdbcType=INTEGER,mode=IN},
|
||||
#{v_snapshot_generation,jdbcType=BIGINT,mode=IN},
|
||||
#{v_snapshot_max_age_seconds,jdbcType=INTEGER,mode=IN},
|
||||
#{v_result,mode=OUT,jdbcType=INTEGER},
|
||||
#{v_resultmsg,mode=OUT,jdbcType=VARCHAR},
|
||||
#{v_final_space_id,mode=OUT,jdbcType=VARCHAR}
|
||||
)}
|
||||
</select>
|
||||
|
||||
<select id="updateLed" parameterType="java.util.Map" statementType="CALLABLE">
|
||||
{call proc_update_led(
|
||||
#{v_code,jdbcType=VARCHAR,mode=IN},
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.ruoyi.algorithm.pilot;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Layer1InboundPilotOperationsTest {
|
||||
private final Path root = projectRoot();
|
||||
|
||||
@Test
|
||||
void installerIsAtomicFloorOneOnlyAndRevalidatesLiveBusinessState() throws IOException {
|
||||
String sql = text("scripts/layer1-inbound-pilot/install-layer1-inbound-pilot.sql");
|
||||
|
||||
assertThat(sql).contains(
|
||||
"CREATE PROCEDURE dbo.proc_allot_layer1_inbound_pilot",
|
||||
"ALTER PROCEDURE dbo.proc_allot_layer1_inbound_pilot",
|
||||
"LAYER1_INBOUND_PILOT_STUB",
|
||||
"SET XACT_ABORT ON",
|
||||
"BEGIN TRY",
|
||||
"BEGIN TRANSACTION",
|
||||
"WITH (UPDLOCK, HOLDLOCK)",
|
||||
"@v_warehouse_id <> '01'",
|
||||
"@v_floor <> 1",
|
||||
"alg.shadow_snapshot_state",
|
||||
"DATEDIFF(SECOND",
|
||||
"dbo.mydo_job",
|
||||
"dbo.bas_cargo_space",
|
||||
"dbo.bas_stock",
|
||||
"UPDATE dbo.mydo_job",
|
||||
"UPDATE dbo.bas_cargo_space",
|
||||
"PILOT_LED_UPDATE_CONFLICT",
|
||||
"ROLLBACK TRANSACTION",
|
||||
"COMMIT TRANSACTION");
|
||||
assertThat(sql).contains(
|
||||
"WHERE task_no = @v_task_no\n AND tray_code = @v_tray_code\n AND warehouse_id = @v_warehouse_id",
|
||||
"WHERE job_id = @job_id\n AND task_no = @v_task_no\n AND tray_code = @v_tray_code\n AND warehouse_id = @v_warehouse_id");
|
||||
assertThat(sql).doesNotContain("DROP PROCEDURE dbo.proc_allot_layer1_inbound_pilot");
|
||||
assertThat(sql.toUpperCase(Locale.ROOT)).doesNotContain("CREATE OR ALTER", "STRING_AGG");
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkIsReadOnlyAndRollbackDropsOnlyTheOwnedProcedure() throws IOException {
|
||||
String check = text("scripts/layer1-inbound-pilot/check-layer1-inbound-pilot.sql")
|
||||
.toLowerCase(Locale.ROOT);
|
||||
String rollback = text("scripts/layer1-inbound-pilot/rollback-layer1-inbound-pilot.sql");
|
||||
|
||||
assertThat(check).contains("object_definition", "proc_allot_layer1_inbound_pilot")
|
||||
.contains("procedure is missing", "incomplete installation stub")
|
||||
.doesNotContain("insert ", "update ", "delete ", "merge ", "alter ", "drop ", "create ", "exec ");
|
||||
assertThat(rollback).contains("LAYER1_INBOUND_PILOT_V1", "DROP PROCEDURE dbo.proc_allot_layer1_inbound_pilot")
|
||||
.contains("DB_NAME()", "FT546-wms")
|
||||
.doesNotContain("DROP TABLE", "DELETE FROM", "UPDATE dbo.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cmdWrappersUseSqlcmd13CompatibleIntegratedAuthentication() throws IOException {
|
||||
for (String file : new String[]{"install-layer1-inbound-pilot.cmd", "check-layer1-inbound-pilot.cmd",
|
||||
"rollback-layer1-inbound-pilot.cmd"}) {
|
||||
String script = text("scripts/layer1-inbound-pilot/" + file);
|
||||
assertThat(script).startsWith("@echo off").contains("sqlcmd", "-E", "-b", "-r1")
|
||||
.doesNotContain("powershell");
|
||||
}
|
||||
}
|
||||
|
||||
private String text(String relative) throws IOException {
|
||||
return Files.readString(root.resolve(relative), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private Path projectRoot() {
|
||||
Path current = Path.of("").toAbsolutePath();
|
||||
if (Files.isRegularFile(current.resolve("pom.xml")) && Files.isDirectory(current.resolve("ruoyi-admin"))) {
|
||||
return current;
|
||||
}
|
||||
Path parent = current.getParent();
|
||||
if (parent != null && Files.isRegularFile(parent.resolve("pom.xml"))
|
||||
&& Files.isDirectory(parent.resolve("ruoyi-admin"))) {
|
||||
return parent;
|
||||
}
|
||||
throw new IllegalStateException("Cannot locate project root from " + current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.ruoyi.algorithm.pilot;
|
||||
|
||||
import com.ruoyi.algorithm.shadow.InboundShadowObservation;
|
||||
import com.ruoyi.algorithm.shadow.ShadowPlanningEngine;
|
||||
import com.ruoyi.algorithm.shadow.ShadowSnapshotDescriptor;
|
||||
import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper;
|
||||
import com.ruoyi.api.domain.RequestEnter;
|
||||
import com.ruoyi.job.mydojob.domain.MydoJob;
|
||||
import com.ruoyi.job.mydojob.mapper.MydoJobMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class Layer1InboundPilotServiceTest {
|
||||
private static final Instant NOW = Instant.parse("2026-09-16T03:00:00Z");
|
||||
|
||||
@Mock
|
||||
private ShadowInputMapper shadowInputMapper;
|
||||
@Mock
|
||||
private ShadowPlanningEngine planningEngine;
|
||||
@Mock
|
||||
private MydoJobMapper jobMapper;
|
||||
|
||||
private Layer1InboundPilotProperties properties;
|
||||
private ExecutorService executor;
|
||||
private Layer1InboundPilotService service;
|
||||
private RequestEnter request;
|
||||
private MydoJob job;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new Layer1InboundPilotProperties();
|
||||
properties.setEnabled(true);
|
||||
executor = Executors.newSingleThreadExecutor();
|
||||
service = new Layer1InboundPilotService(properties, shadowInputMapper, planningEngine, jobMapper,
|
||||
Clock.fixed(NOW, ZoneOffset.UTC), executor);
|
||||
request = new RequestEnter();
|
||||
request.setTaskNo("TASK-1");
|
||||
request.setDeviceCode("L1004");
|
||||
request.setFloor("1");
|
||||
job = new MydoJob();
|
||||
job.setTaskNo("TASK-1");
|
||||
job.setTrayCode("TRAY-1");
|
||||
job.setWarehouseId("01");
|
||||
job.setInOrOut("10");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
service.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledOrOutOfScopeRequestDoesNotTouchPlanningOrMutation() {
|
||||
properties.setEnabled(false);
|
||||
assertThat(service.attempt(request, job).status()).isEqualTo(Layer1InboundPilotResult.Status.NOT_ELIGIBLE);
|
||||
|
||||
properties.setEnabled(true);
|
||||
request.setFloor("2");
|
||||
assertThat(service.attempt(request, job).status()).isEqualTo(Layer1InboundPilotResult.Status.NOT_ELIGIBLE);
|
||||
|
||||
request.setFloor("1");
|
||||
job.setWarehouseId("02");
|
||||
assertThat(service.attempt(request, job).status()).isEqualTo(Layer1InboundPilotResult.Status.NOT_ELIGIBLE);
|
||||
verify(shadowInputMapper, never()).selectPublishedSnapshot(any());
|
||||
verify(jobMapper, never()).allocateLayer1InboundPilot(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void configuredScopeCannotMoveThePilotOutsideWarehouse01Floor1() {
|
||||
properties.setWarehouseId("02");
|
||||
job.setWarehouseId("02");
|
||||
|
||||
assertThat(service.attempt(request, job).status())
|
||||
.isEqualTo(Layer1InboundPilotResult.Status.NOT_ELIGIBLE);
|
||||
|
||||
properties.setWarehouseId("01");
|
||||
job.setWarehouseId("01");
|
||||
properties.setFloor(2);
|
||||
request.setFloor("2");
|
||||
|
||||
assertThat(service.attempt(request, job).status())
|
||||
.isEqualTo(Layer1InboundPilotResult.Status.NOT_ELIGIBLE);
|
||||
verify(shadowInputMapper, never()).selectPublishedSnapshot(any());
|
||||
verify(jobMapper, never()).allocateLayer1InboundPilot(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulPlanCommitsOnlyTheProposedFloorOneSpace() {
|
||||
when(shadowInputMapper.selectPublishedSnapshot("01")).thenReturn(snapshot(NOW.minusSeconds(10)));
|
||||
when(planningEngine.planInbound(any(), eq("TASK-1"), eq("TRAY-1"), eq(1)))
|
||||
.thenReturn(readyObservation());
|
||||
doAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
HashMap<String, Object> arguments = invocation.getArgument(0);
|
||||
arguments.put("v_result", 10);
|
||||
arguments.put("v_resultmsg", "OK");
|
||||
arguments.put("v_final_space_id", "SPACE-101");
|
||||
return null;
|
||||
}).when(jobMapper).allocateLayer1InboundPilot(any());
|
||||
|
||||
Layer1InboundPilotResult result = service.attempt(request, job);
|
||||
|
||||
assertThat(result.status()).isEqualTo(Layer1InboundPilotResult.Status.COMMITTED);
|
||||
assertThat(result.finalSpaceId()).isEqualTo("SPACE-101");
|
||||
verify(planningEngine).planInbound(any(), eq("TASK-1"), eq("TRAY-1"), eq(1));
|
||||
verify(jobMapper).allocateLayer1InboundPilot(any());
|
||||
verify(shadowInputMapper).insertPilotAudit(any(), eq("01"), eq(3028L), eq("COMMITTED"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleSnapshotFallsBackWithoutCallingTheMutationProcedure() {
|
||||
when(shadowInputMapper.selectPublishedSnapshot("01")).thenReturn(snapshot(NOW.minusSeconds(61)));
|
||||
|
||||
Layer1InboundPilotResult result = service.attempt(request, job);
|
||||
|
||||
assertThat(result.status()).isEqualTo(Layer1InboundPilotResult.Status.FALLBACK);
|
||||
assertThat(result.fallbackReason()).isEqualTo("SNAPSHOT_STALE");
|
||||
verify(planningEngine, never()).planInbound(any(), any(), any(), anyInt());
|
||||
verify(jobMapper, never()).allocateLayer1InboundPilot(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedPlanFallsBackWithoutCallingTheMutationProcedure() {
|
||||
when(shadowInputMapper.selectPublishedSnapshot("01")).thenReturn(snapshot(NOW.minusSeconds(5)));
|
||||
when(planningEngine.planInbound(any(), eq("TASK-1"), eq("TRAY-1"), eq(1)))
|
||||
.thenReturn(InboundShadowObservation.unavailable(3028L, "01", "TASK-1",
|
||||
"REJECTED_NO_FEASIBLE_LOCATION", "none"));
|
||||
|
||||
Layer1InboundPilotResult result = service.attempt(request, job);
|
||||
|
||||
assertThat(result.status()).isEqualTo(Layer1InboundPilotResult.Status.FALLBACK);
|
||||
assertThat(result.fallbackReason()).contains("REJECTED_NO_FEASIBLE_LOCATION");
|
||||
verify(jobMapper, never()).allocateLayer1InboundPilot(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void planningTimeoutCancelsReadOnlyWorkAndNeverCallsTheMutationProcedure() throws Exception {
|
||||
properties.setPlanningTimeoutMillis(20L);
|
||||
when(shadowInputMapper.selectPublishedSnapshot("01")).thenReturn(snapshot(NOW.minusSeconds(5)));
|
||||
when(planningEngine.planInbound(any(), eq("TASK-1"), eq("TRAY-1"), eq(1)))
|
||||
.thenAnswer(invocation -> {
|
||||
Thread.sleep(500L);
|
||||
return readyObservation();
|
||||
});
|
||||
|
||||
Layer1InboundPilotResult result = service.attempt(request, job);
|
||||
|
||||
assertThat(result.status()).isEqualTo(Layer1InboundPilotResult.Status.FALLBACK);
|
||||
assertThat(result.fallbackReason()).isEqualTo("PLAN_TIMEOUT");
|
||||
verify(jobMapper, never()).allocateLayer1InboundPilot(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mutationConflictFallsBackAndDoesNotRetryThePilotWriter() {
|
||||
when(shadowInputMapper.selectPublishedSnapshot("01")).thenReturn(snapshot(NOW.minusSeconds(5)));
|
||||
when(planningEngine.planInbound(any(), eq("TASK-1"), eq("TRAY-1"), eq(1)))
|
||||
.thenReturn(readyObservation());
|
||||
doAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
HashMap<String, Object> arguments = invocation.getArgument(0);
|
||||
arguments.put("v_result", 22);
|
||||
arguments.put("v_resultmsg", "SPACE_CONFLICT");
|
||||
return null;
|
||||
}).when(jobMapper).allocateLayer1InboundPilot(any());
|
||||
|
||||
Layer1InboundPilotResult result = service.attempt(request, job);
|
||||
|
||||
assertThat(result.status()).isEqualTo(Layer1InboundPilotResult.Status.FALLBACK);
|
||||
assertThat(result.fallbackReason()).contains("MUTATION_22");
|
||||
verify(jobMapper).allocateLayer1InboundPilot(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ambiguousMutationFailureReturnsExistingAssignmentInsteadOfStartingASecondWriter() {
|
||||
when(shadowInputMapper.selectPublishedSnapshot("01")).thenReturn(snapshot(NOW.minusSeconds(5)));
|
||||
when(planningEngine.planInbound(any(), eq("TASK-1"), eq("TRAY-1"), eq(1)))
|
||||
.thenReturn(readyObservation());
|
||||
doThrow(new IllegalStateException("connection lost after execute"))
|
||||
.when(jobMapper).allocateLayer1InboundPilot(any());
|
||||
MydoJob assigned = new MydoJob();
|
||||
assigned.setCargoSpaceId("SPACE-101");
|
||||
when(jobMapper.selectInJobByTaskNo("TASK-1")).thenReturn(assigned);
|
||||
|
||||
Layer1InboundPilotResult result = service.attempt(request, job);
|
||||
|
||||
assertThat(result.status()).isEqualTo(Layer1InboundPilotResult.Status.COMMITTED);
|
||||
assertThat(result.finalSpaceId()).isEqualTo("SPACE-101");
|
||||
verify(jobMapper).allocateLayer1InboundPilot(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mutationFailureWithNoCommittedAssignmentFallsBackToLegacy() {
|
||||
when(shadowInputMapper.selectPublishedSnapshot("01")).thenReturn(snapshot(NOW.minusSeconds(5)));
|
||||
when(planningEngine.planInbound(any(), eq("TASK-1"), eq("TRAY-1"), eq(1)))
|
||||
.thenReturn(readyObservation());
|
||||
doThrow(new IllegalStateException("procedure rolled back"))
|
||||
.when(jobMapper).allocateLayer1InboundPilot(any());
|
||||
when(jobMapper.selectInJobByTaskNo("TASK-1")).thenReturn(job);
|
||||
|
||||
Layer1InboundPilotResult result = service.attempt(request, job);
|
||||
|
||||
assertThat(result.status()).isEqualTo(Layer1InboundPilotResult.Status.FALLBACK);
|
||||
assertThat(result.fallbackReason()).isEqualTo("MUTATION_ROLLED_BACK_IllegalStateException");
|
||||
}
|
||||
|
||||
private ShadowSnapshotDescriptor snapshot(Instant synchronizedAt) {
|
||||
ShadowSnapshotDescriptor descriptor = new ShadowSnapshotDescriptor();
|
||||
descriptor.setSnapshotGeneration(3028L);
|
||||
descriptor.setWarehouseId("01");
|
||||
descriptor.setSnapshotStatus("PUBLISHED");
|
||||
descriptor.setSynchronizedAt(synchronizedAt);
|
||||
descriptor.setSourceSignature("A1B2C3");
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
private InboundShadowObservation readyObservation() {
|
||||
return new InboundShadowObservation(true, 3028L, "01", NOW.minusSeconds(5), "A1B2C3",
|
||||
"TASK-1", "READY", "SPACE-101", "floor one proposal");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.ruoyi.algorithm.shadow;
|
||||
|
||||
import com.ruoyi.algorithm.integration.AlgorithmProperties;
|
||||
import com.ruoyi.algorithm.integration.SchedulingMode;
|
||||
import com.ruoyi.algorithm.pilot.Layer1InboundPilotProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.context.properties.bind.Bindable;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
@@ -29,6 +30,9 @@ class ShadowConfigurationTest {
|
||||
AlgorithmProperties algorithm = binder
|
||||
.bind("fortucky.algorithm", Bindable.of(AlgorithmProperties.class))
|
||||
.orElseThrow(() -> new IllegalStateException("fortucky.algorithm configuration is missing"));
|
||||
Layer1InboundPilotProperties pilot = binder
|
||||
.bind("fortucky.inbound-pilot", Bindable.of(Layer1InboundPilotProperties.class))
|
||||
.orElseThrow(() -> new IllegalStateException("fortucky.inbound-pilot configuration is missing"));
|
||||
|
||||
assertThat(shadow.isEnabled()).isFalse();
|
||||
assertThat(shadow.getWarehouseId()).isEmpty();
|
||||
@@ -39,6 +43,11 @@ class ShadowConfigurationTest {
|
||||
assertThat(algorithm.getMode()).isEqualTo(SchedulingMode.SHADOW);
|
||||
assertThat(algorithm.isDatabaseWritesEnabled()).isTrue();
|
||||
assertThat(algorithm.getWarehouseId()).isEqualTo("01");
|
||||
assertThat(pilot.isEnabled()).isFalse();
|
||||
assertThat(pilot.getWarehouseId()).isEqualTo("01");
|
||||
assertThat(pilot.getFloor()).isEqualTo(1);
|
||||
assertThat(pilot.getPlanningTimeoutMillis()).isEqualTo(1500);
|
||||
assertThat(pilot.getSnapshotMaxAgeSeconds()).isEqualTo(60);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -53,6 +62,20 @@ class ShadowConfigurationTest {
|
||||
assertThat(algorithm.isLegacyFallbackOnTechnicalError()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void floorOnePilotRequiresItsOwnExplicitEnablement() throws IOException {
|
||||
MockEnvironment environment = new MockEnvironment()
|
||||
.withProperty("FORTUCKY_INBOUND_PILOT_ENABLED", "true");
|
||||
|
||||
Layer1InboundPilotProperties pilot = applicationBinder(environment)
|
||||
.bind("fortucky.inbound-pilot", Bindable.of(Layer1InboundPilotProperties.class))
|
||||
.orElseThrow(() -> new IllegalStateException("fortucky.inbound-pilot configuration is missing"));
|
||||
|
||||
assertThat(pilot.isEnabled()).isTrue();
|
||||
assertThat(pilot.getWarehouseId()).isEqualTo("01");
|
||||
assertThat(pilot.getFloor()).isEqualTo(1);
|
||||
}
|
||||
|
||||
private Binder applicationBinder(MockEnvironment environment) throws IOException {
|
||||
MutablePropertySources propertySources = environment.getPropertySources();
|
||||
List<PropertySource<?>> yaml = new YamlPropertySourceLoader()
|
||||
|
||||
@@ -81,6 +81,31 @@ class ShadowPlanningEngineTest {
|
||||
verify(facade, never()).planInbound(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void floorPilotPlanningForwardsTheRequiredTargetFloor() {
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
SchedulingFacade facade = mock(SchedulingFacade.class);
|
||||
when(mapper.selectInboundInput("01", 42L, "TASK-1", "TRAY-1"))
|
||||
.thenReturn(inboundReference("01"));
|
||||
when(mapper.selectInboundEligibleSpaces("01", 42L, "TASK-1", "TRAY-1"))
|
||||
.thenReturn(java.util.List.of("SPACE-9"));
|
||||
when(mapper.countInboundEligibleSpace("01", 42L, "TASK-1", "TRAY-1", "SPACE-9"))
|
||||
.thenReturn(1);
|
||||
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
|
||||
when(facade.planInboundFromPublishedSnapshot(eq("01"), any()))
|
||||
.thenReturn(new InboundPlanningResult(InboundPlan.assigned(inboundAssignment()),
|
||||
VersionToken.numeric(8)));
|
||||
ShadowPlanningEngine engine = engine(facade, mapper);
|
||||
|
||||
engine.planInbound(SNAPSHOT, "TASK-1", "TRAY-1", 1);
|
||||
|
||||
ArgumentCaptor<com.ruoyi.algorithm.inbound.InboundRequest> request =
|
||||
ArgumentCaptor.forClass(com.ruoyi.algorithm.inbound.InboundRequest.class);
|
||||
verify(facade).planInboundFromPublishedSnapshot(eq("01"), request.capture());
|
||||
assertThat(request.getValue().targetFloor()).isEqualTo(1);
|
||||
assertThat(request.getValue().allowedFloors()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectOutboundKeepsRawWarehouseDiagnosticButPlansForPublishedWarehouse() {
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.ruoyi.api.service.impl;
|
||||
|
||||
import com.ruoyi.algorithm.shadow.InboundShadowCompletion;
|
||||
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
|
||||
import com.ruoyi.algorithm.pilot.Layer1InboundPilotResult;
|
||||
import com.ruoyi.algorithm.pilot.Layer1InboundPilotService;
|
||||
import com.ruoyi.api.domain.RequestEnter;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.job.mydojob.domain.MydoJob;
|
||||
@@ -35,6 +37,8 @@ class ToWmsServiceImplShadowTest {
|
||||
private ShadowObservationGateway shadowObservationGateway;
|
||||
@Mock
|
||||
private InboundShadowCompletion shadowCompletion;
|
||||
@Mock
|
||||
private Layer1InboundPilotService layer1InboundPilotService;
|
||||
@InjectMocks
|
||||
private ToWmsServiceImpl service;
|
||||
|
||||
@@ -50,6 +54,8 @@ class ToWmsServiceImplShadowTest {
|
||||
beforeLegacy = inboundJob("TRAY-1", null);
|
||||
legacyResult = inboundJob("TRAY-1", "SPACE-LEGACY");
|
||||
when(mydoJobMapper.selectInJobByTaskNo("TASK-1")).thenReturn(beforeLegacy, legacyResult);
|
||||
when(layer1InboundPilotService.attempt(request, beforeLegacy))
|
||||
.thenReturn(Layer1InboundPilotResult.notEligible());
|
||||
}
|
||||
|
||||
private void prepareSuccessfulLegacyAllocation() {
|
||||
@@ -142,6 +148,85 @@ class ToWmsServiceImplShadowTest {
|
||||
verify(mydoJobMapper, times(1)).allotInJobSpace(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void committedFloorOnePilotReturnsReloadedJobWithoutShadowOrLegacyWriter() {
|
||||
request.setFloor("1");
|
||||
beforeLegacy.setWarehouseId("01");
|
||||
MydoJob pilotResult = inboundJob("TRAY-1", "SPACE-PILOT");
|
||||
when(mydoJobMapper.selectInJobByTaskNo("TASK-1")).thenReturn(beforeLegacy, pilotResult);
|
||||
when(layer1InboundPilotService.attempt(request, beforeLegacy))
|
||||
.thenReturn(Layer1InboundPilotResult.committed("SPACE-PILOT"));
|
||||
|
||||
AjaxResult result = service.requestEnterByTaskNo(request);
|
||||
|
||||
assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(pilotResult);
|
||||
verify(mydoJobMapper, never()).allotInJobSpace(any());
|
||||
verify(shadowObservationGateway, never()).observeInbound(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void committedPilotWithMissingReloadReturnsAnErrorWithoutStartingLegacy() {
|
||||
request.setFloor("1");
|
||||
beforeLegacy.setWarehouseId("01");
|
||||
when(mydoJobMapper.selectInJobByTaskNo("TASK-1"))
|
||||
.thenReturn(beforeLegacy, (MydoJob) null);
|
||||
when(layer1InboundPilotService.attempt(request, beforeLegacy))
|
||||
.thenReturn(Layer1InboundPilotResult.committed("SPACE-PILOT"));
|
||||
|
||||
AjaxResult result = service.requestEnterByTaskNo(request);
|
||||
|
||||
assertThat(result.get(AjaxResult.CODE_TAG)).isEqualTo(500);
|
||||
assertThat(result.get(AjaxResult.MSG_TAG).toString()).contains("已提交但任务重读失败");
|
||||
verify(mydoJobMapper, never()).allotInJobSpace(any());
|
||||
verify(shadowObservationGateway, never()).observeInbound(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pilotConflictFallsBackToTheExistingLegacyPathExactlyOnce() {
|
||||
prepareSuccessfulLegacyAllocation();
|
||||
request.setFloor("1");
|
||||
beforeLegacy.setWarehouseId("01");
|
||||
when(layer1InboundPilotService.attempt(request, beforeLegacy))
|
||||
.thenReturn(Layer1InboundPilotResult.fallback("SPACE_CONFLICT"));
|
||||
when(shadowObservationGateway.observeInbound("TASK-1", "TRAY-1"))
|
||||
.thenReturn(shadowCompletion);
|
||||
|
||||
AjaxResult result = service.requestEnterByTaskNo(request);
|
||||
|
||||
assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(legacyResult);
|
||||
verify(mydoJobMapper, times(1)).allotInJobSpace(any());
|
||||
verify(shadowObservationGateway).observeInbound("TASK-1", "TRAY-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pilotBoundaryFailureCannotSkipLegacyAllocation() {
|
||||
prepareSuccessfulLegacyAllocation();
|
||||
when(layer1InboundPilotService.attempt(request, beforeLegacy))
|
||||
.thenThrow(new IllegalStateException("pilot unavailable"));
|
||||
when(shadowObservationGateway.observeInbound("TASK-1", "TRAY-1"))
|
||||
.thenReturn(shadowCompletion);
|
||||
|
||||
AjaxResult result = service.requestEnterByTaskNo(request);
|
||||
|
||||
assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(legacyResult);
|
||||
verify(mydoJobMapper, times(1)).allotInJobSpace(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void indeterminatePilotWriteStopsBeforeLegacyToAvoidASecondWriter() {
|
||||
request.setFloor("1");
|
||||
beforeLegacy.setWarehouseId("01");
|
||||
when(layer1InboundPilotService.attempt(request, beforeLegacy))
|
||||
.thenReturn(Layer1InboundPilotResult.indeterminate("MUTATION_STATE_UNKNOWN"));
|
||||
|
||||
AjaxResult result = service.requestEnterByTaskNo(request);
|
||||
|
||||
assertThat(result.get(AjaxResult.CODE_TAG)).isEqualTo(500);
|
||||
assertThat(result.get(AjaxResult.MSG_TAG).toString()).contains("状态待确认");
|
||||
verify(mydoJobMapper, never()).allotInJobSpace(any());
|
||||
verify(shadowObservationGateway, never()).observeInbound(any(), any());
|
||||
}
|
||||
|
||||
private MydoJob inboundJob(String trayNo, String cargoSpaceId) {
|
||||
MydoJob job = new MydoJob();
|
||||
job.setInOrOut("10");
|
||||
|
||||
Reference in New Issue
Block a user