feat: prepare warehouse 01 floor 1 inbound pilot

This commit is contained in:
zgw
2026-09-16 14:12:12 +08:00
parent 2632a57d73
commit 1225cf7287
25 changed files with 1465 additions and 3 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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()) {

View File

@@ -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,

View File

@@ -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<>();

View File

@@ -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);

View File

@@ -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}

View File

@@ -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,

View File

@@ -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},

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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()

View File

@@ -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);

View File

@@ -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");