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,38 @@
# FT546WMS Floor 1 Inbound Pilot Implementation Plan
**Goal:** Prepare, but do not activate, a warehouse `01` floor `1` inbound pilot with atomic mutation and automatic Legacy fallback.
**Constraints:** No field restart, process switch, SQL installation, or production business-data write. SQL Server 2016 syntax only. The independent pilot switch remains disabled in every prepared startup file.
## Task 1: Routing contract
- [x] Add failing configuration tests for the disabled default and explicit pilot properties.
- [x] Add failing service tests for out-of-scope Legacy routing, successful pilot single-writer routing, conflict fallback, and exception fallback.
- [x] Add the pilot properties, result contract, and the minimal `ToWmsServiceImpl` routing seam.
## Task 2: Floor-constrained bounded planning
- [x] Add a failing planning test proving `targetFloor=1` is forwarded to the algorithm.
- [x] Add a synchronous pilot planner that accepts only a current published warehouse `01` snapshot.
- [x] Run planning in a dedicated single-thread daemon executor with a bounded wait; timeout/cancellation remains read-only.
- [x] Reject missing, stale, changing, non-ready, or non-assigned plans and route them to Legacy.
## Task 3: Atomic production mutation
- [x] Add mapper contract tests and static SQL safety tests first.
- [x] Add `dbo.proc_allot_layer1_inbound_pilot` with one transaction, `XACT_ABORT`, update/range locks, live floor/lane/space checks, guarded row counts, and LED parity.
- [x] Add Java mutation result handling and post-exception assignment resolution so an uncertain commit never triggers a second writer.
- [x] Record best-effort `INBOUND_PILOT` evidence in existing `alg.decision_log`.
## Task 4: Operations package
- [x] Add SQL Server 2016 install, read-only check, rollback, and CMD wrappers.
- [x] Add a disabled IDEA run configuration template and a concise field runbook.
- [x] Ensure every prepared configuration sets `FORTUCKY_INBOUND_PILOT_ENABLED=false`.
## Task 5: Verification and delivery
- [x] Run focused red/green tests, all affected module tests, and offline packaging.
- [x] Inspect the JAR for the new classes/configuration and verify no secret is packaged by this change.
- [x] Request an independent code review and address critical or important findings.
- [ ] Prepare and upload versioned artifacts only; do not install, restart, switch, or activate.

View File

@@ -0,0 +1,56 @@
# FT546WMS Warehouse 01 Floor 1 Inbound Pilot Design
## Goal
Prepare a narrowly scoped inbound pilot for warehouse `01`, floor `1`, without enabling it or changing the running field system. The candidate remains Legacy-only by default. A later operator-controlled restart may enable the pilot after the database procedure has been installed and checked.
## Safety invariants
1. `FORTUCKY_INBOUND_PILOT_ENABLED` defaults to `false` and is independent of global algorithm mode.
2. Only a request with `RequestEnter.floor == "1"` and a job whose warehouse is equivalent to `01` is eligible.
3. Non-eligible requests execute the existing Legacy path unchanged.
4. Planning is read-only, uses one published shadow snapshot, is restricted to floor `1`, and has a bounded caller wait.
5. The algorithm writer and `dbo.proc_allot_space` are mutually exclusive. A committed pilot result never calls Legacy.
6. Missing, stale, changing, rejected, timed-out, or failed planning falls back to Legacy.
7. The production mutation is one SQL Server transaction. It revalidates the job, tray, warehouse, floor, snapshot generation and age, selected space, lane continuity, outbound obstruction, duplicate assignment, and live occupancy before updating anything.
8. A mutation conflict or a proven rollback falls back to Legacy. An ambiguous client failure is resolved by rereading the job; an existing assignment is returned rather than risking a second writer.
9. The mutation updates only the same business state owned by the Legacy inbound allocation path: the selected `mydo_job`, selected `bas_cargo_space`, and entry LED row for `L1004` or `L1005`.
10. Every eligible attempt writes best-effort diagnostic evidence to existing `alg.decision_log`; diagnostic failure cannot change the business result.
11. Outbound, relocation, other warehouses, and other floors remain unchanged.
## Request flow
1. Load the inbound job using the existing task number lookup.
2. If it is already allocated, return it unchanged.
3. Ask the floor-1 pilot router for an allocation.
4. When the pilot is disabled or the request is outside scope, immediately continue with the existing shadow-observation plus Legacy allocation path.
5. For an eligible request, load and validate the current published snapshot, then plan asynchronously with `targetFloor=1` and a bounded wait.
6. If planning returns one conservative eligible proposal, call the dedicated atomic mutation procedure once.
7. On procedure success or a reread that proves an assignment already exists, return the reloaded job and do not call Legacy.
8. For every other outcome, invoke the existing Legacy procedure exactly once and preserve its response.
## Atomic mutation contract
The prepared procedure is `dbo.proc_allot_layer1_inbound_pilot`. It accepts task, tray, device, selected space, warehouse, floor, snapshot generation, and maximum snapshot age. It returns a numeric result, message, and final space.
Within `SET XACT_ABORT ON` and one transaction it uses update/range locks to prove:
- exactly one unfinished inbound job matches task and tray;
- the job does not already point at another space;
- the requested published snapshot is still current and fresh;
- the selected live cargo space belongs to warehouse `01`, floor `1`, area A/C, is empty, unlocked, and has no stock;
- no active job already owns or targets that space;
- the lane has no locked or moving (`3`/`4`) space and no active outbound job;
- no physically inner free space exists, so the proposal remains the next legal slot.
The guarded updates must each affect exactly one row. Any failed guard rolls back and returns a non-success code. SQL exceptions roll back and escape to the Java resolver.
## Evidence
`alg.decision_log` uses decision type `INBOUND_PILOT`. The reason records task, tray, proposal, final space, elapsed time, outcome, and fallback reason. The snapshot generation is stored in `input_version`. Evidence is diagnostic only and never participates in the business transaction.
## Deployment boundary
This change prepares source, tests, SQL install/check/rollback files, a JAR, and a disabled IDEA configuration. Preparation does not execute the SQL installer, upload over the running JAR, restart WMS, or set the pilot switch to true.
Later activation requires this order: stop the candidate WMS during an agreed window, install and check the procedure, start the prepared JAR with shadow enabled and pilot disabled, verify Legacy operation, then perform a separate restart with the floor-1 pilot enabled. Rollback is a restart with the pilot disabled; the procedure may remain installed or be removed only after the pilot is disabled.

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

View File

@@ -0,0 +1,31 @@
# Warehouse 01 Floor 1 Inbound Pilot
This directory is a preparation package. Do not enable the pilot during preparation.
## Prepared state
- `FORTUCKY_INBOUND_PILOT_ENABLED=false`
- Legacy remains the only inbound writer.
- The pilot scope is fixed to warehouse `01`, floor `1`. A different configured warehouse or floor disables pilot routing.
- No database script in this directory is run automatically.
- Outbound, relocation, other floors, and other warehouses are unchanged.
## Later maintenance-window order
1. Confirm the current WMS is stopped and retain its known-good IDEA configuration.
2. Run `install-layer1-inbound-pilot.cmd "lpc:." "FT546-wms"`.
3. Run `check-layer1-inbound-pilot.cmd "lpc:." "FT546-wms"` and retain the output.
4. Start the candidate with `FORTUCKY_INBOUND_PILOT_ENABLED=false` and verify normal Legacy operation.
5. Only after a separate approval, restart with `FORTUCKY_INBOUND_PILOT_ENABLED=true`.
## Immediate application rollback
Stop the candidate and restart it with `FORTUCKY_INBOUND_PILOT_ENABLED=false`. This restores Legacy-only routing. The procedure can remain installed because the disabled application never calls it.
After the pilot is disabled, optional database cleanup is:
```cmd
rollback-layer1-inbound-pilot.cmd "lpc:." "FT546-wms" CONFIRM_LAYER1_PILOT_ROLLBACK
```
The rollback SQL rejects every database except `FT546-wms` and drops only the procedure carrying the package ownership marker.

View File

@@ -0,0 +1,11 @@
@echo off
setlocal
if "%~2"=="" (
echo Usage: %~nx0 SQL_SERVER DATABASE
exit /b 2
)
set "SCRIPT_DIR=%~dp0"
sqlcmd -S "%~1" -d "%~2" -E -b -r1 -i "%SCRIPT_DIR%check-layer1-inbound-pilot.sql"
if errorlevel 1 exit /b 1
echo LAYER1_INBOUND_PILOT_CHECK_OK
exit /b 0

View File

@@ -0,0 +1,31 @@
SET NOCOUNT ON;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
IF DB_NAME() <> N'FT546-wms'
THROW 51130, 'Layer1 pilot check requires FT546-wms', 1;
IF OBJECT_ID(N'dbo.proc_allot_layer1_inbound_pilot', N'P') IS NULL
THROW 51131, 'Layer1 pilot procedure is missing', 1;
DECLARE @definition nvarchar(max) =
OBJECT_DEFINITION(OBJECT_ID(N'dbo.proc_allot_layer1_inbound_pilot'));
IF CHARINDEX(N'LAYER1_INBOUND_PILOT_V1', @definition) = 0
THROW 51132, 'Layer1 pilot procedure has unknown ownership', 1;
IF CHARINDEX(N'LAYER1_INBOUND_PILOT_STUB', @definition) > 0
THROW 51133, 'Layer1 pilot procedure is an incomplete installation stub', 1;
IF OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NULL
OR OBJECT_ID(N'alg.cargo_space_snapshot', N'U') IS NULL
OR OBJECT_ID(N'alg.decision_log', N'U') IS NULL
THROW 51134, 'Required shadow objects are missing', 1;
SELECT DB_NAME() AS database_name, 'READY' AS procedure_state;
SELECT warehouse_id, snapshot_generation, snapshot_status, synchronized_at,
DATEDIFF(SECOND, synchronized_at, SYSUTCDATETIME()) AS snapshot_age_seconds
FROM alg.shadow_snapshot_state
WHERE warehouse_id = '01';
SELECT COUNT(*) AS floor1_total,
SUM(CASE WHEN status = '1' AND locked = 'N'
AND NULLIF(LTRIM(RTRIM(tray_code)), '') IS NULL THEN 1 ELSE 0 END) AS floor1_live_available
FROM dbo.bas_cargo_space
WHERE warehouse_id = '01' AND TRY_CONVERT(int, the_floor) = 1;

View File

@@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="FT546WMS-LAYER1-PILOT-DISABLED" type="Application" factoryName="Application">
<envs>
<env name="FORTUCKY_SHADOW_ENABLED" value="true" />
<env name="FORTUCKY_ALGORITHM_MODE" value="SHADOW" />
<env name="FORTUCKY_ALGORITHM_WAREHOUSE_ID" value="01" />
<env name="FORTUCKY_INBOUND_PILOT_ENABLED" value="false" />
<env name="FORTUCKY_INBOUND_PILOT_WAREHOUSE_ID" value="01" />
<env name="FORTUCKY_INBOUND_PILOT_FLOOR" value="1" />
</envs>
<option name="MAIN_CLASS_NAME" value="com.ruoyi.RuoYiApplication" />
<module name="ruoyi-admin" />
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@@ -0,0 +1,14 @@
@echo off
setlocal
if "%~2"=="" (
echo Usage: %~nx0 SQL_SERVER DATABASE
exit /b 2
)
set "SCRIPT_DIR=%~dp0"
echo Installing disabled-by-default floor 1 pilot database procedure...
sqlcmd -S "%~1" -d "%~2" -E -b -r1 -i "%SCRIPT_DIR%install-layer1-inbound-pilot.sql"
if errorlevel 1 exit /b 1
call "%SCRIPT_DIR%check-layer1-inbound-pilot.cmd" "%~1" "%~2"
if errorlevel 1 exit /b 1
echo LAYER1_INBOUND_PILOT_INSTALL_OK
exit /b 0

View File

@@ -0,0 +1,316 @@
/* LAYER1_INBOUND_PILOT_V1 - SQL Server 2016 RTM compatible. */
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF DB_NAME() <> N'FT546-wms'
THROW 51100, 'Layer1 pilot installer requires FT546-wms', 1;
IF SCHEMA_ID(N'alg') IS NULL
OR OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NULL
OR OBJECT_ID(N'alg.cargo_space_snapshot', N'U') IS NULL
OR OBJECT_ID(N'alg.decision_log', N'U') IS NULL
THROW 51101, 'Shadow v3 snapshot objects are required', 1;
IF OBJECT_ID(N'dbo.mydo_job', N'U') IS NULL
OR OBJECT_ID(N'dbo.mydo_job_detail', N'U') IS NULL
OR OBJECT_ID(N'dbo.bas_cargo_space', N'U') IS NULL
OR OBJECT_ID(N'dbo.bas_stock', N'U') IS NULL
OR OBJECT_ID(N'dbo.bas_led', N'U') IS NULL
THROW 51102, 'Required Legacy WMS objects are missing', 1;
IF OBJECT_ID(N'dbo.proc_allot_layer1_inbound_pilot', N'P') IS NULL
BEGIN
EXEC(N'CREATE PROCEDURE dbo.proc_allot_layer1_inbound_pilot AS
BEGIN
SET NOCOUNT ON;
/* LAYER1_INBOUND_PILOT_V1 LAYER1_INBOUND_PILOT_STUB */
THROW 51103, ''Layer1 pilot installation did not complete'', 1;
END;');
END
ELSE IF CHARINDEX(N'LAYER1_INBOUND_PILOT_V1',
OBJECT_DEFINITION(OBJECT_ID(N'dbo.proc_allot_layer1_inbound_pilot'))) = 0
THROW 51104, 'Refusing to replace an unowned procedure', 1;
GO
/* LAYER1_INBOUND_PILOT_V1 */
ALTER PROCEDURE dbo.proc_allot_layer1_inbound_pilot
@v_task_no varchar(80),
@v_tray_code varchar(80),
@v_code varchar(80),
@v_space_id varchar(80),
@v_warehouse_id varchar(80),
@v_floor int,
@v_snapshot_generation bigint,
@v_snapshot_max_age_seconds int,
@v_result int OUTPUT,
@v_resultmsg varchar(255) OUTPUT,
@v_final_space_id varchar(80) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET @v_result = 90;
SET @v_resultmsg = 'PILOT_NOT_COMPLETED';
SET @v_final_space_id = NULL;
IF @v_warehouse_id <> '01' OR @v_floor <> 1
BEGIN
SET @v_result = 30;
SET @v_resultmsg = 'PILOT_SCOPE_REJECTED';
RETURN;
END;
IF @v_snapshot_generation <= 0 OR @v_snapshot_max_age_seconds NOT BETWEEN 5 AND 300
BEGIN
SET @v_result = 31;
SET @v_resultmsg = 'PILOT_CONFIGURATION_REJECTED';
RETURN;
END;
DECLARE @current_generation bigint;
DECLARE @snapshot_status varchar(20);
DECLARE @snapshot_time datetime2;
DECLARE @job_count int;
DECLARE @tray_job_count int;
DECLARE @job_id varchar(80);
DECLARE @existing_space_id varchar(80);
DECLARE @lane_code varchar(80);
DECLARE @actual_floor int;
DECLARE @depth int;
DECLARE @lane_row_count int;
DECLARE @start_time varchar(80) = CONVERT(varchar, GETDATE(), 120);
DECLARE @product_id nvarchar(max);
DECLARE @product_name nvarchar(max);
DECLARE @quantity nvarchar(max);
DECLARE @spec nvarchar(max);
BEGIN TRY
BEGIN TRANSACTION;
SELECT @current_generation = snapshot_generation,
@snapshot_status = snapshot_status,
@snapshot_time = synchronized_at
FROM alg.shadow_snapshot_state WITH (UPDLOCK, HOLDLOCK)
WHERE warehouse_id = @v_warehouse_id;
IF @current_generation IS NULL
OR @current_generation <> @v_snapshot_generation
OR @snapshot_status <> 'PUBLISHED'
OR @snapshot_time IS NULL
OR DATEDIFF(SECOND, @snapshot_time, SYSUTCDATETIME()) NOT BETWEEN 0 AND @v_snapshot_max_age_seconds
BEGIN
ROLLBACK TRANSACTION;
SET @v_result = 21;
SET @v_resultmsg = 'SNAPSHOT_STALE_OR_CHANGED';
RETURN;
END;
SELECT @job_count = COUNT(*),
@job_id = MAX(job_id),
@existing_space_id = MAX(NULLIF(LTRIM(RTRIM(cargo_space_id)), ''))
FROM dbo.mydo_job WITH (UPDLOCK, HOLDLOCK)
WHERE task_no = @v_task_no
AND tray_code = @v_tray_code
AND warehouse_id = @v_warehouse_id
AND TRY_CONVERT(int, in_or_out) < 20;
SELECT @tray_job_count = COUNT(*)
FROM dbo.mydo_job WITH (UPDLOCK, HOLDLOCK)
WHERE tray_code = @v_tray_code
AND TRY_CONVERT(int, in_or_out) < 20;
IF @job_count <> 1 OR @tray_job_count <> 1
BEGIN
ROLLBACK TRANSACTION;
SET @v_result = 20;
SET @v_resultmsg = 'JOB_OR_TRAY_SCOPE_CHANGED';
RETURN;
END;
IF @existing_space_id IS NOT NULL
BEGIN
ROLLBACK TRANSACTION;
SET @v_final_space_id = @existing_space_id;
SET @v_result = CASE WHEN @existing_space_id = @v_space_id THEN 11 ELSE 12 END;
SET @v_resultmsg = 'JOB_ALREADY_ALLOCATED';
RETURN;
END;
SELECT @lane_code = group_coding_no,
@actual_floor = TRY_CONVERT(int, the_floor),
@depth = TRY_CONVERT(int, group_coding)
FROM dbo.bas_cargo_space WITH (UPDLOCK, HOLDLOCK)
WHERE cargo_space_id = @v_space_id
AND warehouse_id = @v_warehouse_id
AND TRY_CONVERT(int, the_floor) = @v_floor
AND status = '1'
AND locked = 'N'
AND NULLIF(LTRIM(RTRIM(tray_code)), '') IS NULL
AND this_cargo_area IN ('A', 'C');
IF @lane_code IS NULL OR @depth IS NULL OR @actual_floor <> @v_floor
BEGIN
ROLLBACK TRANSACTION;
SET @v_result = 22;
SET @v_resultmsg = 'SPACE_CONFLICT';
RETURN;
END;
IF NOT EXISTS (
SELECT 1
FROM alg.cargo_space_snapshot WITH (READCOMMITTEDLOCK)
WHERE warehouse_id = @v_warehouse_id
AND snapshot_generation = @v_snapshot_generation
AND space_id = @v_space_id
AND lane_code = @lane_code
AND TRY_CONVERT(int, floor_no) = @v_floor
AND space_status = '1'
AND locked = 'N'
AND NULLIF(LTRIM(RTRIM(tray_code)), '') IS NULL)
BEGIN
ROLLBACK TRANSACTION;
SET @v_result = 22;
SET @v_resultmsg = 'SPACE_NOT_IN_PLANNED_SNAPSHOT';
RETURN;
END;
/* Lock the complete live lane before checking continuity and obstruction. */
SELECT @lane_row_count = COUNT(*)
FROM dbo.bas_cargo_space WITH (UPDLOCK, HOLDLOCK)
WHERE warehouse_id = @v_warehouse_id AND group_coding_no = @lane_code;
IF @lane_row_count = 0
BEGIN
ROLLBACK TRANSACTION;
SET @v_result = 23;
SET @v_resultmsg = 'LANE_NOT_FOUND';
RETURN;
END;
IF EXISTS (
SELECT 1 FROM dbo.bas_stock WITH (UPDLOCK, HOLDLOCK)
WHERE cargo_space_id = @v_space_id)
OR EXISTS (
SELECT 1 FROM dbo.mydo_job WITH (UPDLOCK, HOLDLOCK)
WHERE job_id <> @job_id
AND (cargo_space_id = @v_space_id OR target_cargo_space_id = @v_space_id))
BEGIN
ROLLBACK TRANSACTION;
SET @v_result = 22;
SET @v_resultmsg = 'SPACE_ALREADY_OWNED';
RETURN;
END;
IF EXISTS (
SELECT 1
FROM dbo.bas_cargo_space WITH (UPDLOCK, HOLDLOCK)
WHERE warehouse_id = @v_warehouse_id
AND TRY_CONVERT(int, the_floor) = @v_floor
AND status = '3')
OR EXISTS (
SELECT 1
FROM dbo.bas_cargo_space WITH (UPDLOCK, HOLDLOCK)
WHERE warehouse_id = @v_warehouse_id
AND group_coding_no = @lane_code
AND (status IN ('3', '4') OR locked = 'Y'))
OR EXISTS (
SELECT 1
FROM dbo.mydo_job outbound_job WITH (UPDLOCK, HOLDLOCK)
JOIN dbo.bas_cargo_space source_space WITH (UPDLOCK, HOLDLOCK)
ON source_space.cargo_space_id = outbound_job.cargo_space_id
WHERE source_space.warehouse_id = @v_warehouse_id
AND source_space.group_coding_no = @lane_code
AND TRY_CONVERT(int, outbound_job.in_or_out) >= 20)
BEGIN
ROLLBACK TRANSACTION;
SET @v_result = 23;
SET @v_resultmsg = 'LANE_OBSTRUCTED_OR_ACTIVE';
RETURN;
END;
IF EXISTS (
SELECT 1
FROM dbo.bas_cargo_space inner_space WITH (UPDLOCK, HOLDLOCK)
WHERE inner_space.warehouse_id = @v_warehouse_id
AND inner_space.group_coding_no = @lane_code
AND TRY_CONVERT(int, inner_space.group_coding) < @depth
AND inner_space.status = '1'
AND inner_space.locked = 'N'
AND NULLIF(LTRIM(RTRIM(inner_space.tray_code)), '') IS NULL
AND NOT EXISTS (
SELECT 1 FROM dbo.bas_stock inner_stock WITH (UPDLOCK, HOLDLOCK)
WHERE inner_stock.cargo_space_id = inner_space.cargo_space_id))
BEGIN
ROLLBACK TRANSACTION;
SET @v_result = 23;
SET @v_resultmsg = 'LANE_INNER_SPACE_STILL_FREE';
RETURN;
END;
UPDATE dbo.mydo_job
SET cargo_space_id = @v_space_id,
status = '3',
start_time = @start_time,
class_id = CONVERT(varchar(5), @actual_floor)
WHERE job_id = @job_id
AND task_no = @v_task_no
AND tray_code = @v_tray_code
AND warehouse_id = @v_warehouse_id
AND TRY_CONVERT(int, in_or_out) < 20
AND NULLIF(LTRIM(RTRIM(cargo_space_id)), '') IS NULL;
IF @@ROWCOUNT <> 1
THROW 51110, 'PILOT_JOB_UPDATE_CONFLICT', 1;
UPDATE dbo.bas_cargo_space
SET status = '3'
WHERE cargo_space_id = @v_space_id
AND warehouse_id = @v_warehouse_id
AND status = '1'
AND locked = 'N'
AND NULLIF(LTRIM(RTRIM(tray_code)), '') IS NULL;
IF @@ROWCOUNT <> 1
THROW 51111, 'PILOT_SPACE_UPDATE_CONFLICT', 1;
IF @v_code IN ('L1004', 'L1005')
BEGIN
;WITH detail_summary AS (
SELECT product_id,
CAST(SUM(in_quantity) AS varchar) AS sum_quantity,
spec,
product_name
FROM dbo.mydo_job_detail
WHERE job_id = @job_id
GROUP BY product_id, spec, product_name
)
SELECT @product_id = STUFF((SELECT ',' + product_id FROM detail_summary FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, ''),
@product_name = STUFF((SELECT ',' + product_name FROM detail_summary FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, ''),
@quantity = STUFF((SELECT ',' + sum_quantity FROM detail_summary FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, ''),
@spec = STUFF((SELECT ',' + spec FROM detail_summary FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, '');
UPDATE dbo.bas_led
SET tray_code = @v_tray_code,
product_id = @product_id,
quantity = @quantity,
spec = @spec,
send_flag = 0,
model = 1,
product_name = @product_name
WHERE id = CASE WHEN @v_code = 'L1004' THEN 1 ELSE 2 END;
IF @@ROWCOUNT <> 1
THROW 51112, 'PILOT_LED_UPDATE_CONFLICT', 1;
END;
SET @v_final_space_id = @v_space_id;
SET @v_result = 10;
SET @v_resultmsg = 'SUCCESS';
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0
ROLLBACK TRANSACTION;
THROW;
END CATCH;
END;
GO
SELECT 'LAYER1_INBOUND_PILOT_INSTALL_OK' AS result;
GO

View File

@@ -0,0 +1,16 @@
@echo off
setlocal
if "%~1"=="" goto :usage
if "%~2"=="" goto :usage
if /I not "%~3"=="CONFIRM_LAYER1_PILOT_ROLLBACK" (
goto :usage
)
set "SCRIPT_DIR=%~dp0"
sqlcmd -S "%~1" -d "%~2" -E -b -r1 -i "%SCRIPT_DIR%rollback-layer1-inbound-pilot.sql"
if errorlevel 1 exit /b 1
echo LAYER1_INBOUND_PILOT_ROLLBACK_OK
exit /b 0
:usage
echo Usage: %~nx0 SQL_SERVER DATABASE CONFIRM_LAYER1_PILOT_ROLLBACK
exit /b 2

View File

@@ -0,0 +1,16 @@
/* LAYER1_INBOUND_PILOT_V1 rollback removes only the owned procedure. */
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF DB_NAME() <> N'FT546-wms'
THROW 51119, 'Run this script only in FT546-wms', 1;
IF OBJECT_ID(N'dbo.proc_allot_layer1_inbound_pilot', N'P') IS NOT NULL
BEGIN
IF CHARINDEX(N'LAYER1_INBOUND_PILOT_V1',
OBJECT_DEFINITION(OBJECT_ID(N'dbo.proc_allot_layer1_inbound_pilot'))) = 0
THROW 51120, 'Refusing to drop an unowned procedure', 1;
DROP PROCEDURE dbo.proc_allot_layer1_inbound_pilot;
END;
SELECT 'LAYER1_INBOUND_PILOT_ROLLBACK_OK' AS result;