7 Commits

47 changed files with 2706 additions and 14 deletions

View File

@@ -0,0 +1,105 @@
# FT546WMS Takeover Readiness Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Produce a testable candidate that reports equivalent outbound failures correctly, refreshes reliably, and never dispatches a blocked rear outbound before its relocation completes.
**Architecture:** Keep Legacy as the production executor while hardening observation and dispatch safety. Separate refresh timeout from request comparison timeout; classify outbound comparisons by business meaning; enforce the relocation dependency both before WMS HTTP dispatch and, where the WCS contract permits, before WCS execution. ACTIVE takeover remains disabled until a dedicated mutation adapter and field evidence exist.
**Tech Stack:** Java 17, Spring Boot, MyBatis, SQL Server 2016, JUnit 5, Mockito, PowerShell integration fixtures.
**Spec:** User instruction in this thread: relocation precedes associated outbound; completion and inventory update release outbound; WCS must guard bypasses; unrelated channels continue normally.
## Global Constraints
- No production switch, restart, synthetic production task, or business-data mutation during implementation.
- Existing mixed-pilot lane scope and Legacy allocation behavior remain unchanged.
- Every behavior change follows red-green testing and is independently revertible.
- SQL Server 2016 syntax only; no online dependency downloads are assumed.
---
### Task 1: Outbound comparison semantics
**Files:**
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java`
**Interfaces:**
- Consumes: `OutboundShadowObservation.resultCode()` and Legacy numeric result code.
- Produces: stable `MATCH`, `DIFFERENT`, `NOT_COMPARABLE` comparison codes while retaining raw evidence in `reason`.
- [ ] Add a failing test proving shadow `ERROR_STOCK_NOT_FOUND` plus Legacy `3` is an equivalent no-inventory outcome, and successful/technical/skipped cases are not falsely matched.
- [ ] Run only `ShadowPlanningEngineTest` and observe the exact `LEGACY_3` failure.
- [ ] Add a private outbound comparison classifier; do not alter planning or Legacy execution.
- [ ] Re-run focused and shadow-module tests.
- [ ] Commit independently.
### Task 2: Dedicated refresh timeout
**Files:**
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowConfigurationTest.java`
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java`
- Modify: `ruoyi-admin/src/main/resources/application.yml`
**Interfaces:**
- Consumes: `FORTUCKY_SHADOW_REFRESH_TIMEOUT_SECONDS`, default 10 seconds.
- Produces: a refresh-only transaction timeout; the 3-second Legacy comparison deadline stays unchanged.
- [ ] Add failing property/guard tests and a transaction-manager test that distinguishes planning timeout from refresh timeout.
- [ ] Run focused tests and observe missing property/incorrect timeout failure.
- [ ] Use separate transaction templates or per-refresh timeout without sharing mutable template state.
- [ ] Re-run focused and shadow-module tests.
- [ ] Commit independently.
### Task 3: WMS dispatch dependency gate
**Files:**
- Create or modify focused mapper/service files under `ruoyi-admin/src/main/java/com/ruoyi/api` and `ruoyi-admin/src/main/resources/mapper` after locating the existing job mapper contract.
- Create: focused JUnit test for `ToWcsServiceImpl.pushOutMydoJob()`.
- Modify: `scripts/mixed-pilot/tests/behavior.sql` only if an SQL regression is required by the confirmed race.
**Interfaces:**
- Consumes: candidate outbound job plus current lane obstruction / unresolved relocation state.
- Produces: dispatch decision `READY` or `WAITING_FOR_RELOCATION`; only READY calls WCS and advances status.
- [ ] Add a failing service test: blocked rear outbound makes zero HTTP calls, unrelated/front outbounds still dispatch, and completing relocation makes the rear job dispatch exactly once.
- [ ] Reproduce the task-creation race in the SQL scratch fixture if it bypasses the existing status-8 trigger.
- [ ] Add the smallest read-only pre-dispatch guard and atomic claim/update boundary supported by current service patterns.
- [ ] Re-run Java and SQL mixed-pilot regression suites.
- [ ] Commit independently.
### Task 4: WCS defense and release candidate
**Files:**
- Modify only the actual FT546-WCS receiving/execution module identified by repository audit.
- Add focused WCS tests at its receive or execution seam.
- Add deployment and rollback notes under `scripts/` in each affected repository.
**Interfaces:**
- Consumes: WMS job payload and WCS current/declared dependency evidence.
- Produces: explicit accepted/waiting/rejected behavior without treating HTTP receipt as physical completion.
- [ ] Add a failing WCS test proving a blocked rear task cannot enter executable state, including manual/replayed payloads.
- [ ] Implement the minimal defense compatible with the existing WMS/WCS contract; if WCS lacks authoritative obstruction data, reject ambiguous rear tasks rather than inventing state.
- [ ] Run both repository test suites and inspect package contents offline.
- [ ] Prepare versioned artifacts and rollback scripts; do not deploy or switch without a maintenance decision.
- [ ] Request independent code review and address findings.
### Task 5: ACTIVE takeover gate
**Files:**
- No production code change until Tasks 1-4 and field evidence pass.
- Update readiness documentation with exact remaining mutation adapter and rollback requirements.
**Interfaces:**
- Consumes: verified planner result and explicit operator mode.
- Produces: a future single-writer execution path, never simultaneous Legacy and algorithm allocation.
- [ ] Preserve `ShadowSafetyGuard` rejection of ACTIVE in this candidate.
- [ ] Define endpoint-by-endpoint single-writer tests before adding any ACTIVE mutation adapter.
- [ ] Require fresh inbound, successful outbound, no-inventory outbound, refresh and relocation field evidence before implementation approval.
- [ ] Do not claim formal takeover readiness merely because the candidate builds.

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.
- [x] 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

@@ -9,6 +9,7 @@ public class ShadowObservationProperties {
private boolean enabled;
private String warehouseId = "";
private int timeoutSeconds = 3;
private int refreshTimeoutSeconds = 10;
private int snapshotMaxAgeSeconds = 60;
private int quietIntervalMillis = 1000;
@@ -36,6 +37,14 @@ public class ShadowObservationProperties {
this.timeoutSeconds = timeoutSeconds;
}
public int getRefreshTimeoutSeconds() {
return refreshTimeoutSeconds;
}
public void setRefreshTimeoutSeconds(int refreshTimeoutSeconds) {
this.refreshTimeoutSeconds = refreshTimeoutSeconds;
}
public int getSnapshotMaxAgeSeconds() {
return snapshotMaxAgeSeconds;
}

View File

@@ -35,17 +35,19 @@ public class ShadowPlanningEngine {
private final SchedulingFacade schedulingFacade;
private final ShadowInputMapper inputMapper;
private final ShadowObservationProperties properties;
private final TransactionTemplate transactions;
private final TransactionTemplate planningTransactions;
private final TransactionTemplate refreshTransactions;
public ShadowPlanningEngine(SchedulingFacade schedulingFacade, ShadowInputMapper inputMapper,
ShadowObservationProperties properties, PlatformTransactionManager transactionManager) {
this.schedulingFacade = Objects.requireNonNull(schedulingFacade, "schedulingFacade");
this.inputMapper = Objects.requireNonNull(inputMapper, "inputMapper");
this.properties = Objects.requireNonNull(properties, "properties");
this.transactions = new TransactionTemplate(Objects.requireNonNull(transactionManager, "transactionManager"));
this.transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
this.transactions.setIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED);
this.transactions.setTimeout(properties.getTimeoutSeconds());
PlatformTransactionManager requiredTransactionManager =
Objects.requireNonNull(transactionManager, "transactionManager");
this.planningTransactions = transactionTemplate(requiredTransactionManager, properties.getTimeoutSeconds());
this.refreshTransactions = transactionTemplate(
requiredTransactionManager, properties.getRefreshTimeoutSeconds());
}
public InboundShadowObservation planInbound(String taskNo, String trayNo) {
@@ -68,7 +70,7 @@ public class ShadowPlanningEngine {
throw new IllegalArgumentException("shadow snapshot instanceId is required");
}
String warehouseId = configuredWarehouseId();
return transactions.execute(status -> {
return refreshTransactions.execute(status -> {
if (!inputMapper.claimInstance(warehouseId, instanceId, INSTANCE_STALE_SECONDS)) {
throw new IllegalStateException("could not claim shadow snapshot refresh for warehouse " + warehouseId);
}
@@ -80,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,
@@ -215,7 +226,7 @@ public class ShadowPlanningEngine {
public void compareInbound(InboundShadowObservation observation, String legacyResultCode,
String legacySpaceId) {
Objects.requireNonNull(observation, "observation");
transactions.executeWithoutResult(status -> {
planningTransactions.executeWithoutResult(status -> {
validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(),
observation.snapshotSynchronizedAt(), observation.sourceSignature());
int inserted = inputMapper.insertComparison(
@@ -230,20 +241,20 @@ public class ShadowPlanningEngine {
public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) {
Objects.requireNonNull(observation, "observation");
transactions.executeWithoutResult(status -> {
planningTransactions.executeWithoutResult(status -> {
validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(),
observation.snapshotSynchronizedAt(), observation.sourceSignature());
int inserted = inputMapper.insertComparison(
UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE",
observation.snapshotGeneration(), observation.snapshotSynchronizedAt(),
observation.sourceSignature(), "LEGACY_" + nullSafe(legacyResultCode),
observation.sourceSignature(), outboundComparisonCode(observation, legacyResultCode),
outboundComparisonReason(observation, legacyResultCode));
requireComparisonInserted(inserted);
});
}
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);
@@ -292,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()) {
@@ -349,7 +360,7 @@ public class ShadowPlanningEngine {
private <T> T planWithStableSnapshot(PublishedShadowSnapshot snapshot, Supplier<T> planner) {
validateLeasedSnapshot(snapshot);
return transactions.execute(status -> {
return planningTransactions.execute(status -> {
requirePublishedSnapshot(snapshot, "published snapshot changed during planning");
T result = planner.get();
requirePublishedSnapshot(snapshot, "published snapshot changed during planning");
@@ -357,6 +368,15 @@ public class ShadowPlanningEngine {
});
}
private TransactionTemplate transactionTemplate(
PlatformTransactionManager transactionManager, int timeoutSeconds) {
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED);
template.setTimeout(timeoutSeconds);
return template;
}
private void validateLeasedSnapshot(PublishedShadowSnapshot snapshot) {
Objects.requireNonNull(snapshot, "snapshot");
if (snapshot.generation() <= 0) {
@@ -600,6 +620,15 @@ public class ShadowPlanningEngine {
? result + "_CONSERVATIVE" : result;
}
private String outboundComparisonCode(OutboundShadowObservation observation, String legacyCode) {
if (observation.observed()
&& "ERROR_STOCK_NOT_FOUND".equals(observation.resultCode())
&& "3".equals(legacyCode)) {
return "MATCH_NO_INVENTORY";
}
return "LEGACY_" + nullSafe(legacyCode);
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}

View File

@@ -36,6 +36,10 @@ public class ShadowSafetyGuard implements InitializingBean {
if (shadowProperties.getTimeoutSeconds() < 1 || shadowProperties.getTimeoutSeconds() > 30) {
throw new IllegalStateException("Shadow observation timeout must be between 1 and 30 seconds");
}
if (shadowProperties.getRefreshTimeoutSeconds() < 5
|| shadowProperties.getRefreshTimeoutSeconds() > 60) {
throw new IllegalStateException("Shadow refresh timeout must be between 5 and 60 seconds");
}
if (shadowProperties.getSnapshotMaxAgeSeconds() < 5
|| shadowProperties.getSnapshotMaxAgeSeconds() > 300) {
throw new IllegalStateException("Shadow snapshot maximum age must be between 5 and 300 seconds");

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

@@ -51,6 +51,11 @@ public class ToWcsServiceImpl implements IToWcsService {
mydoJobParam.setInOut("2");
List<MydoJob> outMydoJobs = mydoJobService.selectMydoJobListApi(mydoJobParam);
for (MydoJob outMydoJob : outMydoJobs) {
// HTTP success only confirms receipt. A rear outbound stays in status 1 until
// every deeper position in its own lane is physically clear.
if (mydoJobService.isOutboundDispatchBlocked(outMydoJob.getJobId())) {
continue;
}
RequestLog requestLog = requestLogService.getRequestLog(PUSH_OUT_MYDO_JOB, JSONUtil.toJsonStr(outMydoJob), "推送出库任务","wms","wcs");
if ("1".equals(requestLog.getIsSuccessful())){
outMydoJob.setStatus("3");

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

@@ -0,0 +1,46 @@
package com.ruoyi.erp.push.controller;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.erp.push.domain.ErpPushException;
import com.ruoyi.erp.push.domain.ErpPushRetryRequest;
import com.ruoyi.erp.push.service.IErpPushExceptionService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/erp/push-exception")
public class ErpPushExceptionController extends BaseController {
private final IErpPushExceptionService service;
public ErpPushExceptionController(IErpPushExceptionService service) {
this.service = service;
}
@PreAuthorize("@ss.hasPermi('erp:pushException:list')")
@GetMapping("/list")
public TableDataInfo list(ErpPushException query) {
startPage();
List<ErpPushException> list = service.selectFailureList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('erp:pushException:retry')")
@Log(title = "ERP推送异常重推", businessType = BusinessType.UPDATE)
@PostMapping("/retry")
public AjaxResult retry(@RequestBody ErpPushRetryRequest request) {
if (service.retry(request)) {
return AjaxResult.success("已加入重试队列");
}
return AjaxResult.error("单据状态已变化,可能已被处理,请刷新列表");
}
}

View File

@@ -0,0 +1,63 @@
package com.ruoyi.erp.push.domain;
import com.ruoyi.common.core.domain.BaseEntity;
public class ErpPushException extends BaseEntity {
private static final long serialVersionUID = 1L;
private String documentType;
private String recordId;
private String documentNo;
private String businessReference;
private String failureTime;
private String errorMessage;
public String getDocumentType() {
return documentType;
}
public void setDocumentType(String documentType) {
this.documentType = documentType;
}
public String getRecordId() {
return recordId;
}
public void setRecordId(String recordId) {
this.recordId = recordId;
}
public String getDocumentNo() {
return documentNo;
}
public void setDocumentNo(String documentNo) {
this.documentNo = documentNo;
}
public String getBusinessReference() {
return businessReference;
}
public void setBusinessReference(String businessReference) {
this.businessReference = businessReference;
}
public String getFailureTime() {
return failureTime;
}
public void setFailureTime(String failureTime) {
this.failureTime = failureTime;
}
public String getErrorMessage() {
return errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
}

View File

@@ -0,0 +1,22 @@
package com.ruoyi.erp.push.domain;
public class ErpPushRetryRequest {
private String documentType;
private String recordId;
public String getDocumentType() {
return documentType;
}
public void setDocumentType(String documentType) {
this.documentType = documentType;
}
public String getRecordId() {
return recordId;
}
public void setRecordId(String recordId) {
this.recordId = recordId;
}
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.erp.push.mapper;
import com.ruoyi.erp.push.domain.ErpPushException;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface ErpPushExceptionMapper {
List<ErpPushException> selectFailureList(ErpPushException query);
int requeueInboundHeader(@Param("recordId") String recordId);
int requeueInboundDetails(@Param("recordId") String recordId);
int requeueOutboundHeader(@Param("recordId") String recordId);
int requeueOutboundDetails(@Param("recordId") String recordId);
int requeueMove(@Param("recordId") String recordId);
}

View File

@@ -0,0 +1,12 @@
package com.ruoyi.erp.push.service;
import com.ruoyi.erp.push.domain.ErpPushException;
import com.ruoyi.erp.push.domain.ErpPushRetryRequest;
import java.util.List;
public interface IErpPushExceptionService {
List<ErpPushException> selectFailureList(ErpPushException query);
boolean retry(ErpPushRetryRequest request);
}

View File

@@ -0,0 +1,69 @@
package com.ruoyi.erp.push.service.impl;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.erp.push.domain.ErpPushException;
import com.ruoyi.erp.push.domain.ErpPushRetryRequest;
import com.ruoyi.erp.push.mapper.ErpPushExceptionMapper;
import com.ruoyi.erp.push.service.IErpPushExceptionService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Locale;
@Service
public class ErpPushExceptionServiceImpl implements IErpPushExceptionService {
private final ErpPushExceptionMapper mapper;
public ErpPushExceptionServiceImpl(ErpPushExceptionMapper mapper) {
this.mapper = mapper;
}
@Override
public List<ErpPushException> selectFailureList(ErpPushException query) {
return mapper.selectFailureList(query);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean retry(ErpPushRetryRequest request) {
if (request == null || StringUtils.isEmpty(request.getDocumentType())
|| StringUtils.isEmpty(request.getRecordId())) {
throw new ServiceException("单据类型和记录编号不能为空");
}
String type = request.getDocumentType().trim().toUpperCase(Locale.ROOT);
String recordId = request.getRecordId().trim();
switch (type) {
case "INBOUND":
return retryInbound(recordId);
case "OUTBOUND":
return retryOutbound(recordId);
case "MOVE":
return mapper.requeueMove(recordId) == 1;
default:
throw new ServiceException("不支持的单据类型: " + request.getDocumentType());
}
}
private boolean retryInbound(String recordId) {
if (mapper.requeueInboundHeader(recordId) != 1) {
return false;
}
if (mapper.requeueInboundDetails(recordId) < 1) {
throw new ServiceException("入库单缺少可重推的失败明细,已取消本次操作");
}
return true;
}
private boolean retryOutbound(String recordId) {
if (mapper.requeueOutboundHeader(recordId) != 1) {
return false;
}
if (mapper.requeueOutboundDetails(recordId) < 1) {
throw new ServiceException("出库单缺少可重推的失败明细,已取消本次操作");
}
return true;
}
}

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);
@@ -95,4 +97,6 @@ public interface MydoJobMapper
void updateLedError(HashMap<String, Object> map);
Integer selectBySpare4OrCargo(InStockinDetail inStockinDetail);
int countOutboundDispatchBlockers(@Param("jobId") String jobId);
}

View File

@@ -111,4 +111,7 @@ public interface IMydoJobService
List<MydoJob> getInJobBySerial(String serial);
void ykCompleted(MydoJob outJob);
/** True while a deeper position in the same lane blocks this outbound. */
boolean isOutboundDispatchBlocked(String jobId);
}

View File

@@ -540,4 +540,9 @@ public class MydoJobServiceImpl implements IMydoJobService {
log.info("移库任务处理成功 jobId:[{}] trayCode:[{}]", outJob.getJobId(), outJob.getTrayCode());
}
}
@Override
public boolean isOutboundDispatchBlocked(String jobId) {
return mydoJobMapper.countOutboundDispatchBlockers(jobId) > 0;
}
}

View File

@@ -189,6 +189,7 @@ fortucky:
enabled: ${FORTUCKY_SHADOW_ENABLED:false}
warehouse-id: ${FORTUCKY_SHADOW_WAREHOUSE_ID:}
timeout-seconds: ${FORTUCKY_SHADOW_TIMEOUT_SECONDS:3}
refresh-timeout-seconds: ${FORTUCKY_SHADOW_REFRESH_TIMEOUT_SECONDS:10}
snapshot-max-age-seconds: ${FORTUCKY_SHADOW_SNAPSHOT_MAX_AGE_SECONDS:60}
quiet-interval-millis: ${FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS:1000}
algorithm:
@@ -196,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

@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.erp.push.mapper.ErpPushExceptionMapper">
<resultMap id="ErpPushExceptionResult" type="com.ruoyi.erp.push.domain.ErpPushException">
<result property="documentType" column="document_type"/>
<result property="recordId" column="record_id"/>
<result property="documentNo" column="document_no"/>
<result property="businessReference" column="business_reference"/>
<result property="failureTime" column="failure_time"/>
<result property="errorMessage" column="error_message"/>
</resultMap>
<sql id="failureUnion">
SELECT 'INBOUND' AS document_type,
CONVERT(nvarchar(100), stockin_guid) AS record_id,
CONVERT(nvarchar(100), stockin_id) AS document_no,
CONVERT(nvarchar(300), stockin_id) AS business_reference,
CONVERT(nvarchar(30), send_time) AS failure_time,
CONVERT(nvarchar(max), msg) AS error_message
FROM in_stockin_back
WHERE send_status = '2' AND stock_source = '1'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
UNION ALL
SELECT 'OUTBOUND',
CONVERT(nvarchar(100), stockout_guid),
CONVERT(nvarchar(100), stockout_id),
CONVERT(nvarchar(300), stockout_id),
CONVERT(nvarchar(30), send_time),
CONVERT(nvarchar(max), msg)
FROM out_stockout_back
WHERE send_status = '2' AND stockout_source = '1'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
UNION ALL
SELECT 'MOVE',
CONVERT(nvarchar(100), id),
CONVERT(nvarchar(100), id),
CONCAT(ISNULL(tray_code, ''), ' ', ISNULL(old_space, ''), ' -> ', ISNULL(new_space, '')),
CONVERT(nvarchar(30), send_time),
CONVERT(nvarchar(max), msg)
FROM move_msg
WHERE status = '2'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
</sql>
<select id="selectFailureList" parameterType="com.ruoyi.erp.push.domain.ErpPushException"
resultMap="ErpPushExceptionResult">
SELECT failures.document_type,
failures.record_id,
failures.document_no,
failures.business_reference,
failures.failure_time,
failures.error_message
FROM (
<include refid="failureUnion"/>
) failures
<where>
<if test="documentType != null and documentType != ''">
AND failures.document_type = #{documentType}
</if>
<if test="documentNo != null and documentNo != ''">
AND failures.document_no LIKE CONCAT('%', #{documentNo}, '%')
</if>
<if test="errorMessage != null and errorMessage != ''">
AND failures.error_message LIKE CONCAT('%', #{errorMessage}, '%')
</if>
</where>
ORDER BY failures.failure_time DESC, failures.record_id DESC
</select>
<update id="requeueInboundHeader">
UPDATE in_stockin_back
SET send_status = '0', send_time = NULL, msg = N'已手动加入重试队列'
WHERE stockin_guid = #{recordId} AND send_status = '2' AND stock_source = '1'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
</update>
<update id="requeueInboundDetails">
UPDATE in_stockin_detail_back
SET send_status = '0', send_time = NULL, msg = N'已手动加入重试队列'
WHERE stockin_guid = #{recordId} AND send_status = '2'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
</update>
<update id="requeueOutboundHeader">
UPDATE out_stockout_back
SET send_status = '0', send_time = NULL, msg = N'已手动加入重试队列'
WHERE stockout_guid = #{recordId} AND send_status = '2' AND stockout_source = '1'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
</update>
<update id="requeueOutboundDetails">
UPDATE out_stockout_detail_back
SET send_status = '0', send_time = NULL, msg = N'已手动加入重试队列'
WHERE stockout_guid = #{recordId} AND send_status = '2'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
</update>
<update id="requeueMove">
UPDATE move_msg
SET status = '0', send_time = NULL, msg = N'已手动加入重试队列'
WHERE id = #{recordId} AND status = '2'
AND TRY_CONVERT(datetime2, send_time, 120) &gt;= DATEADD(month, -1, SYSDATETIME())
</update>
</mapper>

View File

@@ -271,6 +271,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select * from mydo_job where task_no = #{taskNo}
</select>
<select id="countOutboundDispatchBlockers" parameterType="String" resultType="int">
select count(1)
from mydo_job job
join bas_cargo_space source_space on source_space.cargo_space_id = job.cargo_space_id
where job.job_id = #{jobId}
and try_convert(int, job.in_or_out) &gt;= 20
and exists (
select 1
from bas_cargo_space outer_space
where outer_space.warehouse_id = source_space.warehouse_id
and outer_space.group_coding_no = source_space.group_coding_no
and try_convert(int, outer_space.group_coding) &gt; try_convert(int, source_space.group_coding)
and (
isnull(outer_space.status, '') &lt;&gt; '1'
or isnull(outer_space.locked, '') &lt;&gt; 'N'
or nullif(ltrim(rtrim(outer_space.tray_code)), '') is not null
or exists (select 1 from bas_stock stock where stock.cargo_space_id = outer_space.cargo_space_id)
or exists (
select 1 from mydo_job outer_job
where outer_job.job_id &lt;&gt; job.job_id
and (outer_job.cargo_space_id = outer_space.cargo_space_id
or outer_job.target_cargo_space_id = outer_space.cargo_space_id)
)
)
)
</select>
<select id="allotInJobSpace" parameterType="java.util.Map" statementType="CALLABLE">
{call proc_allot_space(
@@ -281,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,15 +30,24 @@ 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();
assertThat(shadow.getTimeoutSeconds()).isEqualTo(3);
assertThat(shadow.getRefreshTimeoutSeconds()).isEqualTo(10);
assertThat(shadow.getSnapshotMaxAgeSeconds()).isEqualTo(60);
assertThat(shadow.getQuietIntervalMillis()).isEqualTo(1000);
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
@@ -52,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);
@@ -211,6 +236,43 @@ class ShadowPlanningEngineTest {
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("LEGACY_10"), any());
}
@Test
void outboundComparisonMatchesWhenBothPlannersReportNoInventory() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
when(mapper.insertComparison(any(), any(), any(), any(Long.class),
any(Instant.class), any(), any(), any())).thenReturn(1);
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
OutboundShadowObservation observation = OutboundShadowObservation.unavailable(
SNAPSHOT, "DETAIL-1", "ERROR_STOCK_NOT_FOUND", "no matching inventory");
engine.compareOutbound(observation, "3");
verify(mapper).insertComparison(any(), eq("01"), eq("OUTBOUND_COMPARE"), eq(42L),
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("MATCH_NO_INVENTORY"),
argThat(reason -> reason.contains("shadow=ERROR_STOCK_NOT_FOUND")
&& reason.contains("legacy=3")));
}
@Test
void outboundComparisonDoesNotMatchNoInventoryAgainstTechnicalOrDifferentOutcomes() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
when(mapper.insertComparison(any(), any(), any(), any(Long.class),
any(Instant.class), any(), any(), any())).thenReturn(1);
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
OutboundShadowObservation technical = OutboundShadowObservation.unavailable(
SNAPSHOT, "DETAIL-1", "ERROR_TECHNICAL", "database failure");
OutboundShadowObservation noInventory = OutboundShadowObservation.unavailable(
SNAPSHOT, "DETAIL-2", "ERROR_STOCK_NOT_FOUND", "no matching inventory");
engine.compareOutbound(technical, "3");
engine.compareOutbound(noInventory, "10");
verify(mapper).insertComparison(any(), eq("01"), eq("OUTBOUND_COMPARE"), eq(42L),
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("LEGACY_3"), any());
verify(mapper).insertComparison(any(), eq("01"), eq("OUTBOUND_COMPARE"), eq(42L),
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("LEGACY_10"), any());
}
@Test
void inboundComparisonRetainsRequiredEvidenceWhenUnicodeReasonIsOversized() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
@@ -278,17 +340,23 @@ class ShadowPlanningEngineTest {
@Test
void refreshClaimsInstanceAndReturnsValidatedPublishedSnapshot() {
ShadowObservationProperties properties = properties();
properties.setTimeoutSeconds(3);
properties.setRefreshTimeoutSeconds(11);
CapturingTransactionManager transactions = new CapturingTransactionManager();
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
when(mapper.claimInstance("01", "INSTANCE-1", 120)).thenReturn(true);
PublishedShadowSnapshot refreshedSnapshot = snapshot(43L, "01");
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(refreshedSnapshot));
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
ShadowPlanningEngine engine = new ShadowPlanningEngine(
mock(SchedulingFacade.class), mapper, properties, transactions);
PublishedShadowSnapshot refreshed = engine.refreshSnapshot(43L, "INSTANCE-1");
verify(mapper).claimInstance("01", "INSTANCE-1", 120);
verify(mapper).refreshSnapshot("01", 43L, "INSTANCE-1");
assertThat(refreshed).isEqualTo(refreshedSnapshot);
assertThat(transactions.definition.getTimeout()).isEqualTo(11);
}
@Test

View File

@@ -76,6 +76,22 @@ class ShadowSafetyGuardTest {
.hasMessageContaining("between 100 and 10000");
}
@Test
void rejectsRefreshTimeoutOutsideValidatedRange() {
ShadowObservationProperties shadow = enabledShadow();
AlgorithmProperties algorithm = new AlgorithmProperties();
algorithm.setMode(SchedulingMode.SHADOW);
algorithm.setDatabaseWritesEnabled(true);
shadow.setRefreshTimeoutSeconds(3);
assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate())
.hasMessageContaining("refresh timeout");
shadow.setRefreshTimeoutSeconds(61);
assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate())
.hasMessageContaining("refresh timeout");
}
private ShadowObservationProperties enabledShadow() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setEnabled(true);

View File

@@ -31,6 +31,23 @@ class ShadowV3OperationsPackageTest {
.isRegularFile();
}
@Test
void erpPushExceptionMenuUsesIdentityGeneratedKeys() throws IOException {
String script = text("sql/upgrade-erp-push-exception-menu.sql");
assertThat(script).contains("SCOPE_IDENTITY()");
assertThat(script).doesNotContain("MAX(menu_id)", "(menu_id, menu_name");
}
@Test
void erpPushExceptionMenuIsAsciiSafeForSqlcmd13() throws IOException {
byte[] script = Files.readAllBytes(root.resolve("sql/upgrade-erp-push-exception-menu.sql"));
for (byte value : script) {
assertThat(value & 0x80).as("SQL script byte must be ASCII").isZero();
}
}
@Test
void preflightIsReadOnlyAndChecksExactSourceAndSingleProcess() throws IOException {
String script = text("scripts/shadow-v3-preflight.cmd");
@@ -292,6 +309,10 @@ class ShadowV3OperationsPackageTest {
CmdResult result = runProcess(new ProcessBuilder(
"git", "clone", "--quiet", root.toString(), destination.toString()), 60);
assertThat(result.exitCode()).as(result.output()).isZero();
CmdResult branch = runProcess(new ProcessBuilder(
"git", "-C", destination.toString(), "branch", "-m",
"test/ft546wms-shadow-v3-20260903"), 15);
assertThat(branch.exitCode()).as(branch.output()).isZero();
}
private String gitHead(Path repository) throws Exception {

View File

@@ -0,0 +1,72 @@
package com.ruoyi.api.service.impl;
import com.ruoyi.job.mydojob.domain.MydoJob;
import com.ruoyi.job.mydojob.service.IMydoJobService;
import com.ruoyi.log.domain.RequestLog;
import com.ruoyi.log.service.IRequestLogService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ToWcsServiceImplTest {
@Test
void blockedRearOutboundWaitsWithoutCallingWcsOrAdvancingStatus() {
IRequestLogService requests = mock(IRequestLogService.class);
IMydoJobService jobs = mock(IMydoJobService.class);
MydoJob rear = outbound("OUT-REAR", "120803");
when(jobs.selectMydoJobListApi(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(rear));
when(jobs.isOutboundDispatchBlocked("OUT-REAR")).thenReturn(true);
ToWcsServiceImpl service = service(requests, jobs);
service.pushOutMydoJob();
verify(requests, never()).getRequestLog(anyString(), anyString(), anyString(), anyString(), anyString());
verify(jobs, never()).updateMydoJob(rear);
}
@Test
void unblockedOutboundDispatchesAndAdvancesExactlyOnce() {
IRequestLogService requests = mock(IRequestLogService.class);
IMydoJobService jobs = mock(IMydoJobService.class);
MydoJob front = outbound("OUT-FRONT", "120703");
when(jobs.selectMydoJobListApi(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(front));
when(jobs.isOutboundDispatchBlocked("OUT-FRONT")).thenReturn(false);
RequestLog accepted = new RequestLog();
accepted.setIsSuccessful("1");
when(requests.getRequestLog(eq(ToWcsServiceImpl.PUSH_OUT_MYDO_JOB), anyString(),
eq("推送出库任务"), eq("wms"), eq("wcs"))).thenReturn(accepted);
ToWcsServiceImpl service = service(requests, jobs);
service.pushOutMydoJob();
verify(requests).getRequestLog(eq(ToWcsServiceImpl.PUSH_OUT_MYDO_JOB), anyString(),
eq("推送出库任务"), eq("wms"), eq("wcs"));
verify(jobs).updateMydoJob(front);
org.assertj.core.api.Assertions.assertThat(front.getStatus()).isEqualTo("3");
}
private ToWcsServiceImpl service(IRequestLogService requests, IMydoJobService jobs) {
ToWcsServiceImpl service = new ToWcsServiceImpl();
ReflectionTestUtils.setField(service, "requestLogService", requests);
ReflectionTestUtils.setField(service, "mydoJobService", jobs);
return service;
}
private MydoJob outbound(String jobId, String spaceId) {
MydoJob job = new MydoJob();
job.setJobId(jobId);
job.setCargoSpaceId(spaceId);
job.setInOrOut("21");
job.setStatus("1");
return job;
}
}

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,81 @@
package com.ruoyi.erp.push.mapper;
import com.ruoyi.erp.push.domain.ErpPushException;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
class ErpPushExceptionMapperSqlTest {
private static final String RESOURCE = "mapper/erp/ErpPushExceptionMapper.xml";
private static final String NAMESPACE =
"com.ruoyi.erp.push.mapper.ErpPushExceptionMapper.";
private static final Pattern FROM_TABLE = Pattern.compile("\\bfrom\\s+([a-z0-9_]+)");
private static final String RECENT_MONTH_PREDICATE =
"try_convert(datetime2, send_time, 120) >= dateadd(month, -1, sysdatetime())";
@Test
void failureListReadsOnlyRecentCurrentPushDocumentTables() throws Exception {
String sql = boundSql("selectFailureList");
assertThat(fromTables(sql)).containsExactly(
"in_stockin_back",
"out_stockout_back",
"move_msg");
assertThat(occurrences(sql, RECENT_MONTH_PREDICATE)).isEqualTo(3);
assertThat(sql).doesNotContain("request_log", "history", "_finish");
}
@Test
void retryUpdatesOnlyRequeueCurrentFailedDocumentsFromTheRecentMonth() throws Exception {
assertThat(boundSql("requeueInboundHeader"))
.contains("update in_stockin_back", "set send_status = '0'", "send_status = '2'",
"stock_source = '1'", RECENT_MONTH_PREDICATE);
assertThat(boundSql("requeueInboundDetails"))
.contains("update in_stockin_detail_back", "set send_status = '0'", "send_status = '2'",
RECENT_MONTH_PREDICATE);
assertThat(boundSql("requeueOutboundHeader"))
.contains("update out_stockout_back", "set send_status = '0'", "send_status = '2'",
"stockout_source = '1'", RECENT_MONTH_PREDICATE);
assertThat(boundSql("requeueOutboundDetails"))
.contains("update out_stockout_detail_back", "set send_status = '0'", "send_status = '2'",
RECENT_MONTH_PREDICATE);
assertThat(boundSql("requeueMove"))
.contains("update move_msg", "set status = '0'", "status = '2'", RECENT_MONTH_PREDICATE);
}
private String boundSql(String statementId) throws Exception {
Configuration configuration = new Configuration();
try (InputStream stream = Objects.requireNonNull(
getClass().getClassLoader().getResourceAsStream(RESOURCE), RESOURCE)) {
new XMLMapperBuilder(stream, configuration, RESOURCE, configuration.getSqlFragments()).parse();
}
return configuration.getMappedStatement(NAMESPACE + statementId)
.getBoundSql(new ErpPushException())
.getSql()
.replaceAll("\\s+", " ")
.toLowerCase(Locale.ROOT);
}
private List<String> fromTables(String sql) {
List<String> tables = new ArrayList<>();
Matcher matcher = FROM_TABLE.matcher(sql);
while (matcher.find()) {
tables.add(matcher.group(1));
}
return tables;
}
private int occurrences(String value, String token) {
return (value.length() - value.replace(token, "").length()) / token.length();
}
}

View File

@@ -0,0 +1,100 @@
package com.ruoyi.erp.push.service;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.erp.push.domain.ErpPushRetryRequest;
import com.ruoyi.erp.push.mapper.ErpPushExceptionMapper;
import com.ruoyi.erp.push.service.impl.ErpPushExceptionServiceImpl;
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 static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ErpPushExceptionServiceTest {
@Mock
private ErpPushExceptionMapper mapper;
private ErpPushExceptionServiceImpl service;
@BeforeEach
void setUp() {
service = new ErpPushExceptionServiceImpl(mapper);
}
@Test
void requeuesInboundHeaderAndDetailsTogether() {
when(mapper.requeueInboundHeader("IN-GUID")).thenReturn(1);
when(mapper.requeueInboundDetails("IN-GUID")).thenReturn(2);
assertTrue(service.retry(request("INBOUND", "IN-GUID")));
verify(mapper).requeueInboundHeader("IN-GUID");
verify(mapper).requeueInboundDetails("IN-GUID");
}
@Test
void rejectsInboundRetryWhenNoFailedDetailsExist() {
when(mapper.requeueInboundHeader("IN-GUID")).thenReturn(1);
when(mapper.requeueInboundDetails("IN-GUID")).thenReturn(0);
assertThrows(ServiceException.class,
() -> service.retry(request("INBOUND", "IN-GUID")));
}
@Test
void staleFailureIsNotRequeuedTwice() {
when(mapper.requeueOutboundHeader("OUT-GUID")).thenReturn(0);
assertFalse(service.retry(request("OUTBOUND", "OUT-GUID")));
verify(mapper, never()).requeueOutboundDetails("OUT-GUID");
}
@Test
void requeuesOutboundHeaderAndDetailsTogether() {
when(mapper.requeueOutboundHeader("OUT-GUID")).thenReturn(1);
when(mapper.requeueOutboundDetails("OUT-GUID")).thenReturn(3);
assertTrue(service.retry(request("OUTBOUND", "OUT-GUID")));
verify(mapper).requeueOutboundDetails("OUT-GUID");
}
@Test
void requeuesMoveWithoutTouchingDocumentDetails() {
when(mapper.requeueMove("MOVE-ID")).thenReturn(1);
assertTrue(service.retry(request("MOVE", "MOVE-ID")));
verify(mapper).requeueMove("MOVE-ID");
}
@Test
void rejectsUnknownDocumentType() {
assertThrows(ServiceException.class,
() -> service.retry(request("OTHER", "ID")));
}
@Test
void rejectsBlankDocumentIdentityBeforeCallingMapper() {
assertThrows(ServiceException.class,
() -> service.retry(request("INBOUND", " ")));
verify(mapper, never()).requeueInboundHeader(org.mockito.ArgumentMatchers.anyString());
}
private ErpPushRetryRequest request(String documentType, String recordId) {
ErpPushRetryRequest request = new ErpPushRetryRequest();
request.setDocumentType(documentType);
request.setRecordId(recordId);
return request;
}
}

View File

@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function listPushExceptions(query) {
return request({
url: '/erp/push-exception/list',
method: 'get',
params: query
})
}
export function retryPushException(data) {
return request({
url: '/erp/push-exception/retry',
method: 'post',
data
})
}

View File

@@ -0,0 +1,224 @@
<template>
<div class="app-container">
<el-form
ref="queryForm"
:model="queryParams"
size="small"
:inline="true"
v-show="showSearch"
label-width="76px"
>
<el-form-item label="单据类型" prop="documentType">
<el-select v-model="queryParams.documentType" placeholder="全部类型" clearable>
<el-option label="入库" value="INBOUND" />
<el-option label="出库" value="OUTBOUND" />
<el-option label="移库" value="MOVE" />
</el-select>
</el-form-item>
<el-form-item label="单据编号" prop="documentNo">
<el-input
v-model="queryParams.documentNo"
placeholder="请输入单据编号"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="错误信息" prop="errorMessage">
<el-input
v-model="queryParams.errorMessage"
placeholder="请输入错误信息"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-refresh-right"
size="mini"
:disabled="selectedRows.length === 0 || retrying"
v-hasPermi="['erp:pushException:retry']"
@click="handleBatchRetry"
>批量重推</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-alert
class="retry-alert"
title="仅显示最近一个月的失败单据。通信中断时 ERP 可能已经收到单据,重推前请人工确认。"
type="warning"
:closable="false"
show-icon
/>
<el-table v-loading="loading" :data="exceptionList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="48" align="center" />
<el-table-column label="类型" prop="documentType" width="84" align="center">
<template slot-scope="scope">
<el-tag size="small" :type="typeTag(scope.row.documentType)">
{{ typeLabel(scope.row.documentType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="单据编号" prop="documentNo" min-width="170" show-overflow-tooltip />
<el-table-column label="业务信息" prop="businessReference" min-width="210" show-overflow-tooltip />
<el-table-column label="失败时间" prop="failureTime" width="168" />
<el-table-column label="错误原因" prop="errorMessage" min-width="220" show-overflow-tooltip />
<el-table-column label="操作" width="150" fixed="right" align="center">
<template slot-scope="scope">
<el-button
type="text"
size="mini"
icon="el-icon-refresh-right"
:disabled="retrying"
:loading="retryingId === rowKey(scope.row)"
v-hasPermi="['erp:pushException:retry']"
@click="handleRetry(scope.row)"
>重推</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script>
import { listPushExceptions, retryPushException } from '@/api/erp/pushException'
export default {
name: 'ErpPushException',
data() {
return {
loading: false,
retrying: false,
retryingId: null,
showSearch: true,
total: 0,
exceptionList: [],
selectedRows: [],
queryParams: {
pageNum: 1,
pageSize: 10,
documentType: null,
documentNo: null,
errorMessage: null
}
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
return listPushExceptions(this.queryParams).then(response => {
this.exceptionList = response.rows || []
this.total = response.total || 0
}).finally(() => {
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
handleSelectionChange(selection) {
this.selectedRows = selection
},
handleRetry(row) {
this.confirmRetry(1).then(() => {
this.retrying = true
this.retryingId = this.rowKey(row)
return retryPushException(this.retryPayload(row))
}).then(() => {
this.$modal.msgSuccess('已加入重试队列')
return this.getList()
}).catch(() => {
if (this.retryingId) {
return this.getList().catch(() => {})
}
}).finally(() => {
this.retrying = false
this.retryingId = null
})
},
async handleBatchRetry() {
const rows = this.selectedRows.slice()
try {
await this.confirmRetry(rows.length)
} catch (error) {
return
}
this.retrying = true
let succeeded = 0
const failed = []
try {
for (const row of rows) {
try {
await retryPushException(this.retryPayload(row))
succeeded += 1
} catch (error) {
failed.push(row.documentNo)
}
}
} finally {
this.retrying = false
await this.getList().catch(() => {})
}
if (failed.length === 0) {
this.$modal.msgSuccess(`已将 ${succeeded} 条单据加入重试队列`)
} else {
this.$modal.msgWarning(`成功 ${succeeded} 条,失败 ${failed.length} 条:${failed.join('、')}`)
}
},
confirmRetry(count) {
return this.$modal.confirm(
`确定重新推送 ${count} 条单据吗?如果 ERP 已收到但响应丢失,重复结果由 ERP 的单号校验规则决定。`
)
},
retryPayload(row) {
return {
documentType: row.documentType,
recordId: row.recordId
}
},
rowKey(row) {
return `${row.documentType}:${row.recordId}`
},
typeLabel(type) {
return { INBOUND: '入库', OUTBOUND: '出库', MOVE: '移库' }[type] || type
},
typeTag(type) {
return { INBOUND: 'success', OUTBOUND: 'danger', MOVE: 'warning' }[type] || 'info'
}
}
}
</script>
<style scoped>
.retry-alert {
margin-bottom: 12px;
}
</style>

View File

@@ -0,0 +1,44 @@
param(
[string]$Server = 'lpc:.',
[string]$MapperFile = "$PSScriptRoot/../../ruoyi-admin/src/main/resources/mapper/job/MydoJobMapper.xml"
)
$ErrorActionPreference='Stop'
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
$testDb='FT546WMS_DispatchGuard_Test_'+[Guid]::NewGuid().ToString('N')
$cn=[Data.SqlClient.SqlConnection]::new("Server=$Server;Database=master;Integrated Security=True;Connect Timeout=8;Application Name=FT546DispatchGuardTests")
function Exec([string]$sql){$c=$cn.CreateCommand();$c.CommandTimeout=15;$c.CommandText=$sql;try{[void]$c.ExecuteNonQuery()}finally{$c.Dispose()}}
function Scalar([string]$sql,[string]$jobId=$null){$c=$cn.CreateCommand();$c.CommandTimeout=15;$c.CommandText=$sql;if($null-ne$jobId){[void]$c.Parameters.Add('@jobId',[Data.SqlDbType]::VarChar,255);$c.Parameters['@jobId'].Value=$jobId};try{$c.ExecuteScalar()}finally{$c.Dispose()}}
function Assert([bool]$ok,[string]$name){if(-not$ok){throw "FAIL: $name"};Write-Output "PASS: $name"}
$created=$false
$cn.Open()
try{
$data=[string](Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultDataPath'))")
$log=[string](Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultLogPath'))")
Exec "CREATE DATABASE [$testDb] ON PRIMARY(NAME=N'$testDb',FILENAME=N'$data$testDb.mdf',SIZE=8MB,MAXSIZE=64MB) LOG ON(NAME=N'${testDb}_log',FILENAME=N'$log${testDb}_log.ldf',SIZE=8MB,MAXSIZE=64MB)"
$created=$true;$cn.ChangeDatabase($testDb);Exec 'SET LOCK_TIMEOUT 1500;SET DEADLOCK_PRIORITY LOW;'
Exec @'
CREATE TABLE bas_cargo_space(cargo_space_id varchar(80),warehouse_id varchar(80),group_coding_no varchar(80),group_coding varchar(20),status varchar(10),locked varchar(10),tray_code varchar(80));
CREATE TABLE bas_stock(stock_id varchar(80),cargo_space_id varchar(80));
CREATE TABLE mydo_job(job_id varchar(255),cargo_space_id varchar(80),target_cargo_space_id varchar(80),in_or_out varchar(10));
INSERT bas_cargo_space VALUES
('REAR','01','LANE-A','1','4','N','REAR-TRAY'),('FRONT','01','LANE-A','2','2','N','FRONT-TRAY'),
('OTHER-REAR','01','LANE-B','1','4','N','OTHER'),('OTHER-FRONT','01','LANE-B','2','1','N',NULL);
INSERT bas_stock VALUES('FRONT-STOCK','FRONT');
INSERT mydo_job VALUES('OUT-REAR','REAR',NULL,'21'),('OUT-FRONT','FRONT',NULL,'21'),('OUT-OTHER','OTHER-REAR',NULL,'21');
'@
$settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Ignore
$reader=[Xml.XmlReader]::Create($MapperFile,$settings);try{$xml=[Xml.XmlDocument]::new();$xml.Load($reader)}finally{$reader.Dispose()}
$node=$xml.SelectSingleNode("//*[local-name()='select' and @id='countOutboundDispatchBlockers']")
if($null-eq$node){throw 'Mapper query missing'}
$sql=$node.InnerText.Replace('#{jobId}','@jobId')
Assert ((Scalar $sql 'OUT-REAR')-gt 0) 'occupied outer position blocks rear outbound'
Assert ((Scalar $sql 'OUT-FRONT')-eq 0) 'front outbound is not blocked by its own lane'
Assert ((Scalar $sql 'OUT-OTHER')-eq 0) 'unrelated lane remains dispatchable'
Exec "DELETE bas_stock;DELETE mydo_job WHERE job_id='OUT-FRONT';UPDATE bas_cargo_space SET status='1',tray_code=NULL WHERE cargo_space_id='FRONT';INSERT mydo_job VALUES('MOVE-FRONT','FRONT','OTHER-FRONT','30')"
Assert ((Scalar $sql 'OUT-REAR')-gt 0) 'unfinished relocation keeps rear outbound waiting'
Exec "DELETE mydo_job WHERE job_id='MOVE-FRONT'"
Assert ((Scalar $sql 'OUT-REAR')-eq 0) 'confirmed relocation completion releases rear outbound'
}finally{
if($created){$cn.ChangeDatabase('master');[Data.SqlClient.SqlConnection]::ClearAllPools();if($testDb-notmatch'^FT546WMS_DispatchGuard_Test_[a-f0-9]{32}$'){throw'Invalid scratch database'};Exec "DROP DATABASE [$testDb]";Write-Output "Scratch database removed: $testDb"}
$cn.Dispose()
}

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;

View File

@@ -0,0 +1,91 @@
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @menu_id bigint;
DECLARE @retry_id bigint;
DECLARE @source_menu_id bigint;
DECLARE @lock_result int;
DECLARE @lock_error nvarchar(2048) = CONVERT(nvarchar(2048), 0xE065D56CB783D65320004500520050002000A8630190025F385EDC8355534753A77E0195);
DECLARE @menu_name nvarchar(255) = CONVERT(nvarchar(255), 0x450052005000A8630190025F385E);
DECLARE @menu_remark nvarchar(500) = CONVERT(nvarchar(500), 0x450052005000DE56204F3159258D17526888);
DECLARE @retry_name nvarchar(255) = CONVERT(nvarchar(255), 0x450052005000025F385ECD91A863);
DECLARE @retry_remark nvarchar(500) = CONVERT(nvarchar(500), 0x3159258D55536E63CD91B065A0526551450052005000DE56204F1F961752);
EXEC @lock_result = sys.sp_getapplock
@Resource = N'FT546WMS:upgrade-erp-push-exception-menu',
@LockMode = N'Exclusive',
@LockOwner = N'Transaction',
@LockTimeout = 10000;
IF @lock_result < 0
BEGIN
ROLLBACK TRANSACTION;
THROW 51000, @lock_error, 1;
END;
SELECT TOP (1) @menu_id = menu_id
FROM dbo.sys_menu
WHERE parent_id = 2 AND path = N'pushException'
ORDER BY menu_id;
IF @menu_id IS NULL
BEGIN
INSERT INTO dbo.sys_menu
(menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon,
create_by, create_time, update_by, update_time, remark)
VALUES
(@menu_name, 2, 2, N'pushException', N'erp/pushException/index', NULL, N'',
N'1', N'0', N'C', N'0', N'0', N'erp:pushException:list', N'warning',
N'admin', SYSDATETIME(), N'', NULL, @menu_remark);
SET @menu_id = CONVERT(bigint, SCOPE_IDENTITY());
END;
SELECT TOP (1) @retry_id = menu_id
FROM dbo.sys_menu
WHERE parent_id = @menu_id AND perms = N'erp:pushException:retry'
ORDER BY menu_id;
IF @retry_id IS NULL
BEGIN
INSERT INTO dbo.sys_menu
(menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon,
create_by, create_time, update_by, update_time, remark)
VALUES
(@retry_name, @menu_id, 1, N'#', N'', NULL, N'',
N'1', N'0', N'F', N'0', N'0', N'erp:pushException:retry', N'#',
N'admin', SYSDATETIME(), N'', NULL, @retry_remark);
SET @retry_id = CONVERT(bigint, SCOPE_IDENTITY());
END;
SELECT TOP (1) @source_menu_id = menu_id
FROM dbo.sys_menu
WHERE perms = N'requestInterface:log:list'
ORDER BY menu_id;
INSERT INTO dbo.sys_role_menu (role_id, menu_id)
SELECT existing.role_id, @menu_id
FROM dbo.sys_role_menu existing
WHERE existing.menu_id = @source_menu_id
AND NOT EXISTS (
SELECT 1 FROM dbo.sys_role_menu target
WHERE target.role_id = existing.role_id AND target.menu_id = @menu_id
);
INSERT INTO dbo.sys_role_menu (role_id, menu_id)
SELECT existing.role_id, @retry_id
FROM dbo.sys_role_menu existing
WHERE existing.menu_id = @source_menu_id
AND NOT EXISTS (
SELECT 1 FROM dbo.sys_role_menu target
WHERE target.role_id = existing.role_id AND target.menu_id = @retry_id
);
COMMIT TRANSACTION;
SELECT N'ERP_PUSH_EXCEPTION_MENU_OK' AS result, @menu_id AS menu_id, @retry_id AS retry_menu_id;