9 Commits

66 changed files with 4074 additions and 16 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

@@ -756,7 +756,7 @@ BEGIN
COUNT(*) AS space_count,
SUM(CASE WHEN cargo.space_status = '2' OR ISNULL(stock.stock_detail_count, 0) > 0 THEN 1 ELSE 0 END) AS occupied_count,
SUM(CASE WHEN cargo.space_status = '1'
AND NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL
AND (NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))
AND NULLIF(LTRIM(RTRIM(cargo.tray_code)), '') IS NULL
AND ISNULL(stock.stock_detail_count, 0) = 0
AND ISNULL(role.role, 'STORAGE') = 'STORAGE'

View File

@@ -775,7 +775,7 @@ BEGIN
SELECT cargo.warehouse_id, cargo.lane_code, COUNT(*) AS space_count,
SUM(CASE WHEN cargo.space_status = '2' OR ISNULL(stock.stock_detail_count, 0) > 0 THEN 1 ELSE 0 END) AS occupied_count,
SUM(CASE WHEN cargo.space_status = '1'
AND NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL
AND (NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))
AND NULLIF(LTRIM(RTRIM(cargo.tray_code)), '') IS NULL
AND ISNULL(stock.stock_detail_count, 0) = 0
AND ISNULL(role.role, 'STORAGE') = 'STORAGE'

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,92 @@
# FT546WMS 指定通道混放试点
这是旧版分配规则的应急补丁,与影子算法是否接管无关。无需更换 WMS/WCS JAR;安装后默认关闭,启用后才允许新增混放。
## 试点范围
| 通道 | 内侧原项目位置 | 外侧可混放位置 | 当前新增容量 |
| --- | --- | --- | --- |
| C1203 | 120803 | 120703 | 1 托盘 |
| C0204 | 020804 | 020704 | 1 托盘 |
| C0205 | 020805 | 020705 | 1 托盘 |
白名单按**整条通道**设置,入库和出库保护覆盖两端。三条双深通道共六个位置,内侧已有货,因此本次最多新增三个同时在库的客居托盘。不会把全库零散空位一起开放。
现场于 2026-09-10 确认所有项目允许分托盘共用通道,并确认这三个外侧位置适配当前托盘。一个托盘仍只能有一个明确项目;999/无项目托盘保持原 B/D 区规则。项目号和库存归属不改写。
原规则找到货位时优先采用原分配。原规则返回无货位时,才尝试白名单通道的外侧空位。每通道最多两个项目,内侧不能越过外侧托盘补货;有在途任务或锁定时拒绝。新增混放时,同层必须至少有两个可通行的 E 区空位。该检查不是长期预留,后续移库仍会重新检查和占用目标。
## CMD 操作
在服务器 CMD 中进入本文件所在目录。命令使用 Windows 集成认证,CMD 会自动调用系统 PowerShell,无需在命令里填写数据库密码。
1. 安装(开关仍关闭):
```bat
call mixed-pilot.cmd install "lpc:." "FT546-wms"
```
出现 `INSTALL_OK` 才表示成功。安装核对三个原存储过程的 SHA-256,版本不符、锁等待超过两秒或执行失败时,事务回滚,不覆盖现场改动。脚本创建触发器和替换过程时需要短暂架构锁;业务繁忙导致安装失败时稍后重试,不能去杀业务会话。
2. 查询配置和现场状态:
```bat
call mixed-pilot.cmd status "lpc:." "FT546-wms"
```
3. 已确认所有项目可混放、托盘适配后开启:
```bat
call mixed-pilot.cmd enable "lpc:." "FT546-wms" CONFIRM-FIT-ALL-PROJECTS
```
出现 `ENABLED` 才表示三条通道均已启用。启用会重新核对内侧库存、外侧空位和在途任务,条件变化则整次拒绝。
4. 像原来一样创建入库任务。出库优先使用**自动分配**,它会按旧程序先为外侧托盘建立移库,再建立内侧出库。手工选择被挡住的内侧库存、却没有外侧腾挪任务时,会明确拒绝,不能手动把等待任务改为已下发或已完成。
## 观察和退出
内侧任务等待期间状态为 `8`,含义是“等待前位清空”;现有界面字典未配置该标签时可能直接显示数字 8。`status` 命令的 `waiting` 给出任务清单。只有外侧货位、库存和在途任务均已清空,内侧任务才恢复状态 `1`,由现有 WMS 定时推送交给 WCS。创建移库任务本身不会释放等待。
先验证一组完整业务:新项目入库 → 前位正常出库 → 再混放一托 → 后位自动出库并完成前位移库。测试通过后再增加通道数量;本包不开放 A 区深通道,也不切换整套新算法。
出现异常时先关闭**新增混放**,无需重启:
```bat
call mixed-pilot.cmd disable "lpc:." "FT546-wms"
```
关闭会保留已混放库存的出库保护,不会自动撤销库存位置或移库任务。不要直接删除触发器或覆盖存储过程。
完整卸载必须先关闭开关,三条试点通道清空、相关移库任务和移出到 E 区的试点托盘也全部处理完:
```bat
call mixed-pilot.cmd uninstall "lpc:." "FT546-wms"
```
卸载恢复三个原过程的完整逻辑、移除试点触发器和辅助过程,保留 `mixed_pilot` 中的原定义与审计表。未清空时拒绝卸载。卸载后的再次安装需要重新准备版本,避免覆盖已有审计资料。
导出观察记录发回分析:
```bat
call mixed-pilot.cmd status "lpc:." "FT546-wms" > mixed-pilot-status.json
```
同时提供对应时间段 WMS/WCS 日志,以及实际入库、前位出库、后位移库出库的任务号和现场结果。仅有安装成功或一段无报错日志不能证明设备完整流程通过。
## 已完成的程序验证
在 SQL Server 2016 上创建独立小型测试数据库,只使用合成数据和读取到的现场原过程,不连接设备或调用业务 HTTP。已验证默认关闭、普通分配优先、白名单与项目一致性、拒绝第三项目、重复分配、前后位派发顺序、移库目标校验、重复完成回调、开关关闭仍保护、两个连接争抢同一空位、安装漂移拒绝及卸载恢复。
还直接执行了现场 `proc_out_auto_allocation`:确认实际生成的移库任务搬的是外侧客居托盘,内侧出库进入等待,移库完成后才放行。这些是数据库行为验证,设备现场往返仍需通过上述小范围业务试点确认。
本补丁替换 `proc_allot_space`、`proc_yk_job_add`、`proc_yk_complete` 三个入口并保留原定义,不修改两个出库分配过程。增加三处数据库触发器,涉及 `mydo_job`、`bas_cargo_space`、`bas_stock`。未登记通道仍使用原分配与派发规则;试点启用期间,入库分配增加短时间串行校验,移库增加目标锁检查,争用时可能需要重试。
开发人员复测(应在安装前采集原过程;测试只创建独立、随机命名的测试数据库):
```bat
powershell.exe -NoProfile -NonInteractive -File capture-test-fixture.ps1 -Server "lpc:." -Database "FT546-wms" > fixture.json
powershell.exe -NoProfile -NonInteractive -File test-mixed-pilot.ps1 -Server "lpc:." -FixtureJson fixture.json
```
测试库初始数据文件与日志各 8 MB,上限各 64 MB,正常结束或失败后都会尝试清理。测试不恢复生产库,不调用 WMS/WCS 的业务接口;原序列号过程用测试序列替代,合成表保留字段类型和顺序、放宽非空约束,因此不覆盖全部生产约束及设备流程。

View File

@@ -0,0 +1,20 @@
param([Parameter(Mandatory=$true)][string]$Server,[Parameter(Mandatory=$true)][string]$Database)
$ErrorActionPreference='Stop'
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
$builder=[Data.SqlClient.SqlConnectionStringBuilder]::new()
$builder['Data Source']=$Server;$builder['Initial Catalog']=$Database;$builder['Integrated Security']=$true
$builder['Connect Timeout']=8;$builder['Application Name']='MixedPilotFixtureReadOnly'
$cn=[Data.SqlClient.SqlConnection]::new($builder.ConnectionString);$cn.Open()
function Query([string]$sql){
$c=$cn.CreateCommand();$c.CommandTimeout=10;$c.CommandText='SET NOCOUNT ON;SET LOCK_TIMEOUT 2000;'+$sql
$t=[Data.DataTable]::new();try{$r=$c.ExecuteReader();try{$t.Load($r)}finally{$r.Dispose()}}finally{$c.Dispose()}
foreach($row in $t.Rows){$o=[ordered]@{};foreach($col in $t.Columns){$v=$row[$col.ColumnName];$o[$col.ColumnName]=if($v -is [DBNull]){$null}else{$v}};[pscustomobject]$o}
}
try {
# Definitions and column metadata only: no production inventory/order/task rows are exported.
$fixture=[ordered]@{
columns=@(Query "SELECT o.name table_name,c.name column_name,TYPE_NAME(c.user_type_id) type_name,c.max_length,c.precision,c.scale,c.is_nullable,c.is_identity FROM sys.objects o JOIN sys.columns c ON c.object_id=o.object_id WHERE o.schema_id=SCHEMA_ID('dbo') AND o.name IN('bas_cargo_space','bas_stock','mydo_job','mydo_job_detail','mydo_job_finish','bas_led','move_msg','out_stockout','out_stockout_detail') ORDER BY o.name,c.column_id")
wms_objects=@(Query "SELECT o.name,m.definition FROM sys.objects o JOIN sys.sql_modules m ON m.object_id=o.object_id WHERE o.schema_id=SCHEMA_ID('dbo') AND o.name IN('cargo_space_group_count','proc_allot_space','proc_yk_job_add','proc_yk_complete','proc_out_auto_allocation','proc_out_hand_allocation')")
}
$fixture|ConvertTo-Json -Depth 6 -Compress
}finally{$cn.Dispose()}

View File

@@ -0,0 +1,101 @@
param(
[Parameter(Mandatory=$true)][string]$Server,
[Parameter(Mandatory=$true)][string]$Database,
[ValidateSet('status','enable','disable','uninstall')][string]$Mode='status',
[switch]$ConfirmPhysicalFit,
[switch]$AllProjectsMayMix,
[switch]$TestDatabase
)
$ErrorActionPreference='Stop'
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
if($TestDatabase -and $Database -notmatch '^FT546WMS_MixedPilot_Test_[a-f0-9]{32}$'){throw 'Invalid test database name'}
if(-not $TestDatabase -and $Database -ine 'FT546-wms'){throw 'Only FT546-wms is supported'}
$builder=[Data.SqlClient.SqlConnectionStringBuilder]::new()
$builder['Data Source']=$Server;$builder['Initial Catalog']=$Database;$builder['Integrated Security']=$true
$builder['Connect Timeout']=8;$builder['Application Name']='FT546MixedPilotControl'
$cn=[Data.SqlClient.SqlConnection]::new($builder.ConnectionString);$cn.Open();$tx=$null
function Cmd([string]$sql){$c=$cn.CreateCommand();$c.CommandText=$sql;$c.CommandTimeout=15;if($tx){$c.Transaction=$tx};$c}
function Exec([string]$sql){$c=Cmd $sql;try{[void]$c.ExecuteNonQuery()}finally{$c.Dispose()}}
function Scalar([string]$sql){$c=Cmd $sql;try{$c.ExecuteScalar()}finally{$c.Dispose()}}
function Query([string]$sql){
$c=Cmd $sql;$t=[Data.DataTable]::new()
try{$r=$c.ExecuteReader();try{$t.Load($r)}finally{$r.Dispose()}}finally{$c.Dispose()}
foreach($row in $t.Rows){$o=[ordered]@{};foreach($col in $t.Columns){$v=$row[$col.ColumnName];$o[$col.ColumnName]=if($v -is [DBNull]){$null}else{$v}};[pscustomobject]$o}
}
try {
Exec 'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET DEADLOCK_PRIORITY LOW;'
if((Scalar "SELECT OBJECT_ID('mixed_pilot.control','U')") -is [DBNull]){
if($Mode -eq 'status'){Write-Output '{"installed":false}';return};throw 'Pilot is not installed'
}
if($Mode -eq 'status'){
$report=[ordered]@{
utc=[DateTime]::UtcNow.ToString('o')
control=@(Query 'SELECT * FROM mixed_pilot.control')
slots=@(Query 'SELECT l.*,s.cargo_space_id,s.group_coding,s.the_floor,s.status,s.locked,s.tray_code FROM mixed_pilot.lane l LEFT JOIN dbo.bas_cargo_space s ON s.warehouse_id=l.warehouse_id AND s.group_coding_no=l.lane_code ORDER BY l.lane_code,s.group_coding')
admissions=@(Query 'SELECT TOP(100) * FROM mixed_pilot.admission ORDER BY created_at DESC')
waiting=@(Query "SELECT j.job_id,j.tray_code,j.cargo_space_id,j.status,h.created_at FROM mixed_pilot.dispatch_hold h JOIN dbo.mydo_job j ON j.job_id=h.job_id WHERE j.status='8'")
moves=@(Query 'SELECT * FROM mixed_pilot.move_audit WHERE completed_at IS NULL')
}
$report|ConvertTo-Json -Depth 6;return
}
$tx=$cn.BeginTransaction()
Exec "DECLARE @r int; EXEC @r=sys.sp_getapplock @Resource='FT546.mixed_pilot.inbound',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=1000; IF @r<0 THROW 51142,'An inbound allocation is active; retry later',1;"
if($Mode -eq 'disable'){
Exec 'UPDATE mixed_pilot.control SET enabled=0 WHERE id=1; UPDATE mixed_pilot.lane SET enabled=0;'
$tx.Commit();$tx.Dispose();$tx=$null;Write-Output 'DISABLED: no new mixed admissions; existing mixed-stock dispatch protection retained';return
}
if((Scalar 'SELECT installed FROM mixed_pilot.control WHERE id=1') -ne 1){throw 'Pilot hooks are already uninstalled'}
Exec "IF EXISTS(SELECT 1 FROM mixed_pilot.module_backup WHERE installed_hash<>HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name))) OR OBJECT_DEFINITION(OBJECT_ID(object_name)) IS NULL) OR EXISTS(SELECT 1 FROM mixed_pilot.installed_module WHERE definition_hash<>HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name))) OR OBJECT_DEFINITION(OBJECT_ID(object_name)) IS NULL) THROW 51143,'Module drift detected; no changes made',1;"
if($Mode -eq 'enable'){
if(-not $ConfirmPhysicalFit -or -not $AllProjectsMayMix){throw 'Enable requires -ConfirmPhysicalFit and -AllProjectsMayMix; do not infer these from empty SQL height fields'}
Exec @'
IF (SELECT COUNT(*) FROM mixed_pilot.lane)<>3 OR EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE warehouse_id<>'01' OR lane_code NOT IN('C1203','C0204','C0205'))
THROW 51144,'Pilot whitelist differs from the reviewed three lanes',1;
DECLARE @locked int;
SELECT @locked=COUNT(*) FROM dbo.bas_cargo_space s WITH(UPDLOCK,HOLDLOCK)
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no;
IF @locked<>6 OR EXISTS(SELECT 1 FROM mixed_pilot.lane l WHERE
(SELECT COUNT(*) FROM dbo.bas_cargo_space s WHERE s.warehouse_id=l.warehouse_id AND s.group_coding_no=l.lane_code AND TRY_CONVERT(int,s.group_coding)=1 AND s.this_cargo_area='C' AND s.status='2' AND s.locked='N' AND NULLIF(s.tray_code,'') IS NOT NULL)<>1
OR (SELECT COUNT(*) FROM dbo.bas_cargo_space s WHERE s.warehouse_id=l.warehouse_id AND s.group_coding_no=l.lane_code AND TRY_CONVERT(int,s.group_coding)=2 AND s.this_cargo_area='C' AND s.status='1' AND s.locked='N' AND NULLIF(s.tray_code,'') IS NULL)<>1)
THROW 51145,'A pilot lane no longer has an occupied rear and empty unlocked front; refresh candidate review',1;
IF EXISTS(SELECT 1 FROM dbo.mydo_job j WITH(UPDLOCK,HOLDLOCK) JOIN dbo.bas_cargo_space s ON s.cargo_space_id=j.cargo_space_id OR s.cargo_space_id=j.target_cargo_space_id OR s.tray_code=j.tray_code
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no)
THROW 51146,'Pilot lane has an active task; no switches changed',1;
IF EXISTS(SELECT 1 FROM dbo.bas_cargo_space s JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
WHERE (TRY_CONVERT(int,s.group_coding)=2 AND EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id))
OR (TRY_CONVERT(int,s.group_coding)=1 AND (
(SELECT COUNT(DISTINCT spare4) FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id)<>1
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id AND (NULLIF(x.spare4,'') IS NULL OR x.spare4='999' OR x.tray_code<>s.tray_code OR x.tray_code IS NULL OR ISNULL(x.out_quantity,0)<>0)))))
THROW 51147,'Pilot inventory/project consistency check failed',1;
UPDATE mixed_pilot.control SET enabled=1,all_projects_may_mix=1 WHERE id=1;
UPDATE mixed_pilot.lane SET enabled=1,physical_fit_confirmed=1;
'@
$tx.Commit();$tx.Dispose();$tx=$null;Write-Output 'ENABLED: C1203/120703, C0204/020704, C0205/020705; at most three simultaneous guest pallets';return
}
# Full uninstall requires all trial stock/tasks to be drained, including relocated trial pallets.
Exec @'
IF EXISTS(SELECT 1 FROM mixed_pilot.control WHERE enabled=1)
THROW 51148,'Disable admission before uninstalling',1;
IF EXISTS(SELECT 1 FROM dbo.bas_cargo_space s JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
WHERE s.status<>'1' OR NULLIF(s.tray_code,'') IS NOT NULL
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id)
OR EXISTS(SELECT 1 FROM dbo.mydo_job j WHERE j.cargo_space_id=s.cargo_space_id OR j.target_cargo_space_id=s.cargo_space_id))
OR EXISTS(SELECT 1 FROM mixed_pilot.move_audit a JOIN dbo.bas_stock s ON s.tray_code=a.tray_code)
OR EXISTS(SELECT 1 FROM mixed_pilot.move_audit a JOIN dbo.mydo_job j ON j.job_id=a.job_id)
OR EXISTS(SELECT 1 FROM mixed_pilot.dispatch_hold h JOIN dbo.mydo_job j ON j.job_id=h.job_id AND j.status='8')
THROW 51149,'Trial lanes or relocated trial pallets are not drained; keep protection installed',1;
'@
$backups=@(Query 'SELECT * FROM mixed_pilot.module_backup')
foreach($b in $backups){
Exec (('SET ANSI_NULLS '+$(if($b.ansi_nulls){'ON'}else{'OFF'}))+'; SET QUOTED_IDENTIFIER '+$(if($b.quoted_identifier){'ON'}else{'OFF'})+';')
$original=[regex]::Replace($b.original_definition,'(?i)\bCREATE\s+PROCEDURE','ALTER PROCEDURE')
Exec $original
}
Exec 'DROP TRIGGER dbo.tr_mixed_pilot_job_guard; DROP TRIGGER dbo.tr_mixed_pilot_slot_release; DROP TRIGGER dbo.tr_mixed_pilot_stock_release;'
foreach($name in @('allot_space_with_fallback','original_allot_space','original_yk_job_add','original_yk_complete','check_inbound','try_fallback','check_move','release_ready_jobs')){Exec "DROP PROCEDURE mixed_pilot.[$name]"}
Exec 'DROP VIEW mixed_pilot.blocked_jobs; UPDATE mixed_pilot.control SET installed=0,enabled=0 WHERE id=1;'
$tx.Commit();$tx.Dispose();$tx=$null
Write-Output 'UNINSTALLED: original procedure behavior restored; audit and original definitions retained in mixed_pilot tables'
} catch {
if($tx){try{$tx.Rollback()}catch{};$tx.Dispose();$tx=$null};throw
} finally {$cn.Dispose()}

View File

@@ -0,0 +1,92 @@
param(
[Parameter(Mandatory=$true)][string]$Server,
[Parameter(Mandatory=$true)][string]$Database,
[switch]$TestDatabase
)
$ErrorActionPreference='Stop'
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
if($TestDatabase -and $Database -notmatch '^FT546WMS_MixedPilot_Test_[a-f0-9]{32}$'){throw 'Invalid isolated test database name'}
if(-not $TestDatabase -and $Database -ine 'FT546-wms'){throw 'Only FT546-wms is supported'}
$builder=[Data.SqlClient.SqlConnectionStringBuilder]::new()
$builder['Data Source']=$Server; $builder['Initial Catalog']=$Database; $builder['Integrated Security']=$true
$builder['Connect Timeout']=8; $builder['Application Name']='FT546MixedPilotInstaller'
$cn=[Data.SqlClient.SqlConnection]::new($builder.ConnectionString);$cn.Open();$tx=$null
function Cmd([string]$sql) {
$c=$cn.CreateCommand();$c.CommandText=$sql;$c.CommandTimeout=15
if($tx){$c.Transaction=$tx};return $c
}
function Exec([string]$sql) {$c=Cmd $sql;try{[void]$c.ExecuteNonQuery()}finally{$c.Dispose()}}
function Scalar([string]$sql) {$c=Cmd $sql;try{$c.ExecuteScalar()}finally{$c.Dispose()}}
function Hash([string]$s) {
$sha=[Security.Cryptography.SHA256]::Create()
try{([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::Unicode.GetBytes($s)))).Replace('-','')}finally{$sha.Dispose()}
}
function RenameProc([string]$sql,[string]$name) {
$regex=[regex]::new('(?i)\bCREATE\s+PROCEDURE\s+(?:\[?dbo\]?\.)?\[?proc_[a-z_]+\]?')
if($regex.Matches($sql).Count -ne 1){throw 'Unexpected procedure header'}
$regex.Replace($sql,('CREATE PROCEDURE [mixed_pilot].['+$name+']'),1)
}
try {
Exec 'SET LOCK_TIMEOUT 2000; SET DEADLOCK_PRIORITY LOW;'
$tx=$cn.BeginTransaction()
Exec "DECLARE @r int; EXEC @r=sys.sp_getapplock @Resource='FT546.mixed_pilot.install',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=0; IF @r<0 THROW 51140,'Another pilot installation is in progress',1;"
if((Scalar "SELECT OBJECT_ID('mixed_pilot.control','U')") -isnot [DBNull]){
if((Scalar 'SELECT installed FROM mixed_pilot.control WHERE id=1') -ne 1){throw 'Previously uninstalled package: audit data retained; prepare a new reviewed installation rather than overwriting it'}
if((Scalar 'SELECT version FROM mixed_pilot.control WHERE id=1') -ne '20260910-1'){throw 'Unsupported existing mixed-pilot version'}
if((Scalar "SELECT COUNT(*) FROM mixed_pilot.module_backup WHERE installed_hash<>HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name))) OR OBJECT_DEFINITION(OBJECT_ID(object_name)) IS NULL") -ne 0){throw 'Installed procedure drift detected; refusing overwrite'}
if((Scalar "SELECT OBJECT_ID('mixed_pilot.installed_module','U')") -is [DBNull]){throw 'Missing installed-module manifest'}
if((Scalar "SELECT COUNT(*) FROM mixed_pilot.installed_module WHERE definition_hash<>HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name))) OR OBJECT_DEFINITION(OBJECT_ID(object_name)) IS NULL") -ne 0){throw 'Pilot helper/trigger drift detected; refusing to report success'}
foreach($table in @('lane','admission','move_audit','dispatch_hold','module_backup')){
if((Scalar "SELECT OBJECT_ID('mixed_pilot.$table','U')") -is [DBNull]){throw "Missing pilot table: $table"}
}
$tx.Commit();$tx.Dispose();$tx=$null;Write-Output 'ALREADY_INSTALLED: switches and data preserved';return
}
$expected=[ordered]@{
proc_allot_space='DFF37262FD49271E9E6E657F01697731F1FF73DCD6DECA1F340107D560E26850'
proc_yk_job_add='72708181352148DAF9490DD59FCBD0E3AC7A8C849EE8FC4AFCBFA0B1AF79AB5B'
proc_yk_complete='C80CD445262BB483E7A7BDCF7DB97A44AD07A09D491869A60C094C5037621131'
}
$original=@{}
foreach($name in $expected.Keys){
$definition=Scalar "SELECT OBJECT_DEFINITION(OBJECT_ID('dbo.$name'))"
if($definition -is [DBNull] -or (Hash $definition) -ne $expected[$name]){throw "Site procedure differs from reviewed baseline: $name. Nothing installed."}
$original[$name]=$definition
}
# No stock/tasks/slot metadata are changed by installation. Admission remains OFF.
Exec "IF SCHEMA_ID('mixed_pilot') IS NOT NULL THROW 51141,'Unexpected pre-existing mixed_pilot schema',1;"
Exec 'CREATE SCHEMA mixed_pilot AUTHORIZATION dbo'
foreach($batch in ([IO.File]::ReadAllText("$PSScriptRoot/objects.sql") -split '(?im)^\s*GO\s*\r?$')){if($batch.Trim()){Exec $batch}}
foreach($name in $expected.Keys){
$c=Cmd "INSERT mixed_pilot.module_backup(object_name,original_definition,original_hash,ansi_nulls,quoted_identifier) SELECT @name,definition,HASHBYTES('SHA2_256',definition),uses_ansi_nulls,uses_quoted_identifier FROM sys.sql_modules WHERE object_id=OBJECT_ID(@name)"
[void]$c.Parameters.AddWithValue('@name',"dbo.$name");try{[void]$c.ExecuteNonQuery()}finally{$c.Dispose()}
$ansi=Scalar "SELECT ansi_nulls FROM mixed_pilot.module_backup WHERE object_name='dbo.$name'"
$quoted=Scalar "SELECT quoted_identifier FROM mixed_pilot.module_backup WHERE object_name='dbo.$name'"
Exec (('SET ANSI_NULLS '+$(if($ansi){'ON'}else{'OFF'}))+'; SET QUOTED_IDENTIFIER '+$(if($quoted){'ON'}else{'OFF'})+';')
Exec (RenameProc $original[$name] ('original_'+$name.Substring(5)))
}
Exec 'SET ANSI_NULLS ON; SET QUOTED_IDENTIFIER ON;'
$inbound=RenameProc $original['proc_allot_space'] 'allot_space_with_fallback'
$noSlot=[regex]::new("(?is)set\s+@v_result\s*=\s*2\s+set\s+@v_resultmsg\s*=\s*'[^']+'\s+RETURN")
if($noSlot.Matches($inbound).Count -ne 2){throw 'Expected two Legacy no-position exits'}
$inbound=$noSlot.Replace($inbound,[Text.RegularExpressions.MatchEvaluator]{param($match)
'EXEC mixed_pilot.try_fallback @v_job_id,@v_cargo_space_id OUTPUT;'+"`r`n"+
'IF @v_cargo_space_id IS NULL BEGIN '+$match.Value+' END;'+"`r`n"+
'GOTO mixed_pilot_destination;'
})
$destination=[regex]::new('(?i)SELECT\s+@v_job_id\s*=\s*job_id\s+from\s+mydo_job\s+where\s+tray_code\s*=\s*@v_tray_code')
if($destination.Matches($inbound).Count -ne 1){throw 'Expected one common Legacy destination block'}
$inbound=$destination.Replace($inbound,@'
mixed_pilot_destination:
EXEC mixed_pilot.check_inbound @v_job_id,@v_cargo_space_id;
SELECT @v_job_id = job_id from mydo_job where tray_code = @v_tray_code
'@)
Exec $inbound
foreach($batch in ([IO.File]::ReadAllText("$PSScriptRoot/wrappers.sql") -split '(?im)^\s*GO\s*\r?$')){if($batch.Trim()){Exec $batch}}
Exec "UPDATE mixed_pilot.module_backup SET installed_hash=HASHBYTES('SHA2_256',OBJECT_DEFINITION(OBJECT_ID(object_name)))"
Exec "INSERT mixed_pilot.installed_module(object_name,definition_hash) SELECT QUOTENAME(OBJECT_SCHEMA_NAME(object_id))+'.'+QUOTENAME(OBJECT_NAME(object_id)),HASHBYTES('SHA2_256',definition) FROM sys.sql_modules WHERE OBJECT_SCHEMA_NAME(object_id)='mixed_pilot' OR OBJECT_NAME(object_id) IN ('tr_mixed_pilot_job_guard','tr_mixed_pilot_slot_release','tr_mixed_pilot_stock_release')"
$tx.Commit();$tx.Dispose();$tx=$null
Write-Output 'INSTALL_OK: pilot OFF; three C lanes configured but not enabled; Legacy definitions backed up in mixed_pilot.module_backup'
} catch {
if($tx){try{$tx.Rollback()}catch{};$tx.Dispose();$tx=$null}
throw
} finally {$cn.Dispose()}

View File

@@ -0,0 +1,18 @@
@echo off
setlocal
if "%~3"=="" goto usage
if /I "%~1"=="install" (
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0install-mixed-pilot.ps1" -Server "%~2" -Database "%~3"
exit /b
)
if /I "%~1"=="enable" (
if /I not "%~4"=="CONFIRM-FIT-ALL-PROJECTS" goto usage
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0control-mixed-pilot.ps1" -Server "%~2" -Database "%~3" -Mode enable -ConfirmPhysicalFit -AllProjectsMayMix
exit /b
)
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0control-mixed-pilot.ps1" -Server "%~2" -Database "%~3" -Mode "%~1"
exit /b
:usage
echo Usage: mixed-pilot.cmd install^|status^|disable^|uninstall "lpc:." "FT546-wms"
echo Enable: mixed-pilot.cmd enable "lpc:." "FT546-wms" CONFIRM-FIT-ALL-PROJECTS
exit /b 2

View File

@@ -0,0 +1,256 @@
CREATE TABLE mixed_pilot.control (
id int NOT NULL PRIMARY KEY CHECK(id=1),
version varchar(30) NOT NULL,
installed bit NOT NULL DEFAULT 1,
enabled bit NOT NULL DEFAULT 0,
all_projects_may_mix bit NOT NULL DEFAULT 0,
min_free_e_slots int NOT NULL DEFAULT 2 CHECK(min_free_e_slots>=2)
);
INSERT mixed_pilot.control(id,version,enabled,all_projects_may_mix) VALUES(1,'20260910-1',0,0);
CREATE TABLE mixed_pilot.lane (
warehouse_id varchar(80) NOT NULL,
lane_code varchar(80) NOT NULL,
enabled bit NOT NULL DEFAULT 0,
physical_fit_confirmed bit NOT NULL DEFAULT 0,
protected bit NOT NULL DEFAULT 0,
PRIMARY KEY(warehouse_id,lane_code)
);
INSERT mixed_pilot.lane(warehouse_id,lane_code) VALUES('01','C1203'),('01','C0204'),('01','C0205');
CREATE TABLE mixed_pilot.module_backup (
object_name sysname NOT NULL PRIMARY KEY,
original_definition nvarchar(max) NOT NULL,
original_hash varbinary(32) NOT NULL,
installed_hash varbinary(32) NULL,
ansi_nulls bit NOT NULL,
quoted_identifier bit NOT NULL
);
CREATE TABLE mixed_pilot.installed_module (
object_name nvarchar(260) NOT NULL PRIMARY KEY,
definition_hash varbinary(32) NOT NULL
);
CREATE TABLE mixed_pilot.admission (
job_id varchar(255) NOT NULL PRIMARY KEY,
lane_code varchar(80) NOT NULL,
cargo_space_id varchar(80) NOT NULL,
tray_code varchar(255) NOT NULL,
resident_project varchar(255) NOT NULL,
guest_project varchar(255) NOT NULL,
created_at datetime2 NOT NULL DEFAULT SYSUTCDATETIME()
);
CREATE TABLE mixed_pilot.move_audit (
job_id varchar(255) NOT NULL PRIMARY KEY,
source_slot varchar(255) NOT NULL,
target_slot varchar(255) NOT NULL,
tray_code varchar(255) NOT NULL,
completed_at datetime2 NULL
);
CREATE TABLE mixed_pilot.dispatch_hold (
job_id varchar(255) NOT NULL PRIMARY KEY,
created_at datetime2 NOT NULL DEFAULT SYSUTCDATETIME(),
released_at datetime2 NULL
);
GO
-- A source position is inaccessible while ANY outer position has cargo or a live task.
CREATE VIEW mixed_pilot.blocked_jobs AS
SELECT j.job_id
FROM dbo.mydo_job j
JOIN dbo.bas_cargo_space s ON s.cargo_space_id=j.cargo_space_id
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no AND l.protected=1
WHERE j.in_or_out>=20 AND EXISTS (
SELECT 1 FROM dbo.bas_cargo_space b
WHERE b.warehouse_id=s.warehouse_id AND b.group_coding_no=s.group_coding_no
AND TRY_CONVERT(int,b.group_coding)>TRY_CONVERT(int,s.group_coding)
AND (ISNULL(b.status,'')<>'1' OR ISNULL(b.locked,'')<>'N' OR NULLIF(b.tray_code,'') IS NOT NULL
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=b.cargo_space_id)
OR EXISTS(SELECT 1 FROM dbo.mydo_job x WHERE x.cargo_space_id=b.cargo_space_id OR x.target_cargo_space_id=b.cargo_space_id))
);
GO
CREATE PROCEDURE mixed_pilot.release_ready_jobs AS
BEGIN
SET NOCOUNT ON;
-- Clearing only a slot flag is insufficient: inventory and front tasks must also be gone.
UPDATE j SET status='1'
FROM dbo.mydo_job j JOIN mixed_pilot.dispatch_hold h ON h.job_id=j.job_id AND h.released_at IS NULL
JOIN dbo.bas_cargo_space s ON s.cargo_space_id=j.cargo_space_id
WHERE j.status='8' AND s.status='4' AND s.tray_code=j.tray_code
AND EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id AND x.tray_code=j.tray_code)
AND NOT EXISTS(SELECT 1 FROM mixed_pilot.blocked_jobs b WHERE b.job_id=j.job_id);
UPDATE h SET released_at=SYSUTCDATETIME()
FROM mixed_pilot.dispatch_hold h
WHERE h.released_at IS NULL AND NOT EXISTS(SELECT 1 FROM dbo.mydo_job j WHERE j.job_id=h.job_id AND j.status='8');
END;
GO
CREATE TRIGGER dbo.tr_mixed_pilot_job_guard ON dbo.mydo_job AFTER INSERT,UPDATE,DELETE AS
BEGIN
SET NOCOUNT ON;
IF TRIGGER_NESTLEVEL(OBJECT_ID('dbo.tr_mixed_pilot_job_guard'))>1 RETURN;
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE enabled=1 OR protected=1) RETURN;
-- Coordinate with inbound reservation before checking whether a lane has just become mixed.
DECLARE @locked int;
SELECT @locked=COUNT(*) FROM dbo.bas_cargo_space s WITH(UPDLOCK,HOLDLOCK)
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
WHERE (l.enabled=1 OR l.protected=1) AND EXISTS(
SELECT 1 FROM inserted i JOIN dbo.bas_cargo_space origin ON origin.cargo_space_id=i.cargo_space_id
WHERE origin.warehouse_id=s.warehouse_id AND origin.group_coding_no=s.group_coding_no AND i.in_or_out>=20);
IF EXISTS(SELECT 1 FROM inserted i JOIN mixed_pilot.blocked_jobs blocked ON blocked.job_id=i.job_id
JOIN dbo.bas_cargo_space s ON s.cargo_space_id=i.cargo_space_id
JOIN dbo.bas_cargo_space front ON front.warehouse_id=s.warehouse_id AND front.group_coding_no=s.group_coding_no
AND TRY_CONVERT(int,front.group_coding)>TRY_CONVERT(int,s.group_coding)
WHERE i.status='1' AND NOT EXISTS(SELECT 1 FROM dbo.mydo_job j WITH(UPDLOCK,HOLDLOCK)
WHERE j.cargo_space_id=front.cargo_space_id AND j.tray_code=front.tray_code AND j.in_or_out>=20 AND j.status IN('1','3')))
THROW 51126,N'外侧尚未安排腾挪,请使用自动分配出库;外侧正在入库时请稍后重试。',1;
INSERT mixed_pilot.dispatch_hold(job_id)
SELECT DISTINCT i.job_id FROM inserted i JOIN mixed_pilot.blocked_jobs b ON b.job_id=i.job_id
WHERE i.status='1' AND NOT EXISTS(SELECT 1 FROM mixed_pilot.dispatch_hold h WITH(UPDLOCK,HOLDLOCK) WHERE h.job_id=i.job_id);
UPDATE j SET status='8'
FROM dbo.mydo_job j JOIN inserted i ON i.job_id=j.job_id
JOIN mixed_pilot.blocked_jobs b ON b.job_id=j.job_id
WHERE j.status='1';
EXEC mixed_pilot.release_ready_jobs;
END;
GO
CREATE TRIGGER dbo.tr_mixed_pilot_slot_release ON dbo.bas_cargo_space AFTER UPDATE AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS(SELECT 1 FROM inserted i JOIN mixed_pilot.lane l ON l.warehouse_id=i.warehouse_id AND l.lane_code=i.group_coding_no AND l.protected=1) RETURN;
EXEC mixed_pilot.release_ready_jobs;
END;
GO
CREATE TRIGGER dbo.tr_mixed_pilot_stock_release ON dbo.bas_stock AFTER UPDATE,DELETE AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS(SELECT 1 FROM deleted d JOIN dbo.bas_cargo_space s ON s.cargo_space_id=d.cargo_space_id
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no AND l.protected=1) RETURN;
EXEC mixed_pilot.release_ready_jobs;
END;
GO
CREATE PROCEDURE mixed_pilot.check_inbound
@job_id varchar(255),@slot varchar(80)
AS
BEGIN
SET NOCOUNT ON;
IF @@TRANCOUNT=0 OR ISNULL(APPLOCK_MODE('public','FT546.mixed_pilot.inbound','Transaction'),'NoLock')<>'Exclusive'
THROW 51129,N'试点入库校验必须由受保护的分配入口调用。',1;
DECLARE @lane varchar(80),@warehouse varchar(80),@depth int,@project varchar(255),@tray varchar(255),@resident varchar(255),@rear varchar(80),@floor int;
SELECT @lane=s.group_coding_no,@warehouse=s.warehouse_id,@depth=TRY_CONVERT(int,s.group_coding),@floor=s.the_floor
FROM dbo.bas_cargo_space s JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
WHERE s.cargo_space_id=@slot AND (l.enabled=1 OR l.protected=1);
IF @lane IS NULL RETURN;
SELECT @project=line_id,@tray=tray_code FROM dbo.mydo_job WHERE job_id=@job_id AND warehouse_id=@warehouse AND in_or_out<20;
IF @project IS NULL OR @project IN ('','999') OR @tray IS NULL
THROW 51101,N'试点货位的任务项目或仓库不明确,请核对入库任务。',1;
IF (SELECT COUNT(*) FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane)<>2
OR (SELECT COUNT(*) FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane AND TRY_CONVERT(int,group_coding)=1)<>1
OR (SELECT COUNT(*) FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane AND TRY_CONVERT(int,group_coding)=2)<>1
OR EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane AND (this_cargo_area<>'C' OR TRY_CONVERT(int,group_coding) NOT IN(1,2) OR TRY_CONVERT(int,group_coding) IS NULL))
THROW 51102,N'试点仅支持已指定的双深C区通道。',1;
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@slot AND status='1' AND locked='N' AND NULLIF(tray_code,'') IS NULL)
OR EXISTS(SELECT 1 FROM dbo.bas_stock WHERE cargo_space_id=@slot)
OR EXISTS(SELECT 1 FROM dbo.mydo_job j JOIN dbo.bas_cargo_space s ON s.cargo_space_id=j.cargo_space_id OR s.cargo_space_id=j.target_cargo_space_id
WHERE s.warehouse_id=@warehouse AND s.group_coding_no=@lane AND j.job_id<>@job_id)
THROW 51103,N'试点通道已有库存或在途任务,请稍后重试。',1;
IF EXISTS(SELECT 1 FROM dbo.bas_cargo_space s WHERE s.warehouse_id=@warehouse AND s.group_coding_no=@lane
AND (s.locked<>'N' OR s.status NOT IN('1','2')))
THROW 51104,N'试点通道正在作业或被锁定,请稍后重试。',1;
IF @depth=1 AND EXISTS(SELECT 1 FROM dbo.bas_cargo_space s WHERE s.warehouse_id=@warehouse AND s.group_coding_no=@lane AND TRY_CONVERT(int,s.group_coding)=2
AND (s.status<>'1' OR NULLIF(s.tray_code,'') IS NOT NULL OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=s.cargo_space_id)))
THROW 51105,N'外侧尚有托盘,不能向通道内侧补货。',1;
SELECT @rear=cargo_space_id FROM dbo.bas_cargo_space WHERE warehouse_id=@warehouse AND group_coding_no=@lane AND TRY_CONVERT(int,group_coding)=1;
IF @depth<>2 OR NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@rear AND status='2') RETURN;
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job_detail WHERE job_id=@job_id)
OR EXISTS(SELECT 1 FROM dbo.mydo_job_detail WHERE job_id=@job_id AND (NULLIF(spare4,'') IS NULL OR spare4<>@project))
OR (SELECT COUNT(DISTINCT spare4) FROM dbo.bas_stock WHERE cargo_space_id=@rear)<>1
OR EXISTS(SELECT 1 FROM dbo.bas_stock x JOIN dbo.bas_cargo_space s ON s.cargo_space_id=x.cargo_space_id
WHERE x.cargo_space_id=@rear AND (NULLIF(x.spare4,'') IS NULL OR x.tray_code<>s.tray_code OR x.tray_code IS NULL OR ISNULL(x.out_quantity,0)<>0))
THROW 51106,N'托盘项目明细不一致或库存已分配,不能参与混放。',1;
SELECT TOP(1) @resident=spare4 FROM dbo.bas_stock WHERE cargo_space_id=@rear;
IF @resident=@project RETURN;
IF @resident='999' OR NOT EXISTS(SELECT 1 FROM mixed_pilot.control WHERE id=1 AND enabled=1 AND all_projects_may_mix=1)
OR NOT EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE warehouse_id=@warehouse AND lane_code=@lane AND enabled=1 AND physical_fit_confirmed=1)
THROW 51107,N'该通道未开放混放或尚未确认托盘适配。',1;
IF (SELECT COUNT(*) FROM dbo.bas_cargo_space e WHERE e.warehouse_id=@warehouse AND e.the_floor=@floor AND e.this_cargo_area='E'
AND e.status='1' AND e.locked='N' AND NULLIF(e.tray_code,'') IS NULL
AND NOT EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=e.cargo_space_id)
AND NOT EXISTS(SELECT 1 FROM dbo.mydo_job x WHERE x.cargo_space_id=e.cargo_space_id OR x.target_cargo_space_id=e.cargo_space_id)
AND NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space b WHERE b.warehouse_id=e.warehouse_id AND b.group_coding_no=e.group_coding_no
AND TRY_CONVERT(int,b.group_coding)>TRY_CONVERT(int,e.group_coding)
AND (b.status<>'1' OR b.locked<>'N' OR NULLIF(b.tray_code,'') IS NOT NULL
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WHERE x.cargo_space_id=b.cargo_space_id)
OR EXISTS(SELECT 1 FROM dbo.mydo_job x WHERE x.cargo_space_id=b.cargo_space_id OR x.target_cargo_space_id=b.cargo_space_id))))
<(SELECT min_free_e_slots FROM mixed_pilot.control WHERE id=1)
THROW 51108,N'同层可用移库空间不足,暂停新增混放。',1;
UPDATE mixed_pilot.lane SET protected=1 WHERE warehouse_id=@warehouse AND lane_code=@lane;
IF EXISTS(SELECT 1 FROM mixed_pilot.admission WHERE job_id=@job_id AND (cargo_space_id<>@slot OR tray_code<>@tray OR resident_project<>@resident OR guest_project<>@project))
THROW 51127,N'该任务已有不同的混放记录,请核对后重新创建任务。',1;
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.admission WHERE job_id=@job_id)
INSERT mixed_pilot.admission(job_id,lane_code,cargo_space_id,tray_code,resident_project,guest_project)
VALUES(@job_id,@lane,@slot,@tray,@resident,@project);
END;
GO
CREATE PROCEDURE mixed_pilot.try_fallback
@job_id varchar(255),@slot varchar(80) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET @slot=NULL;
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.control WHERE id=1 AND enabled=1 AND all_projects_may_mix=1) RETURN;
DECLARE @candidate varchar(80),@last_error nvarchar(2048);
DECLARE candidates CURSOR LOCAL FAST_FORWARD FOR
SELECT s.cargo_space_id FROM dbo.bas_cargo_space s
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
JOIN dbo.mydo_job j ON j.job_id=@job_id AND j.warehouse_id=s.warehouse_id
WHERE l.enabled=1 AND l.physical_fit_confirmed=1 AND s.this_cargo_area='C' AND TRY_CONVERT(int,s.group_coding)=2
AND s.status='1' AND s.locked='N' AND NULLIF(s.tray_code,'') IS NULL
AND EXISTS(SELECT 1 FROM dbo.bas_cargo_space r WHERE r.warehouse_id=s.warehouse_id AND r.group_coding_no=s.group_coding_no AND TRY_CONVERT(int,r.group_coding)=1 AND r.status='2')
AND NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space busy WHERE busy.warehouse_id=s.warehouse_id AND busy.the_floor=s.the_floor AND busy.status='3')
ORDER BY s.the_floor,s.group_coding_no;
OPEN candidates; FETCH NEXT FROM candidates INTO @candidate;
WHILE @@FETCH_STATUS=0
BEGIN
BEGIN TRY
EXEC mixed_pilot.check_inbound @job_id,@candidate;
SET @slot=@candidate;
END TRY
BEGIN CATCH
IF ERROR_NUMBER() NOT BETWEEN 51101 AND 51108 BEGIN CLOSE candidates; DEALLOCATE candidates; THROW; END;
SET @last_error=ERROR_MESSAGE();
END CATCH;
IF @slot IS NOT NULL BREAK;
FETCH NEXT FROM candidates INTO @candidate;
END;
CLOSE candidates; DEALLOCATE candidates;
IF @slot IS NULL AND @last_error IS NOT NULL THROW 51128,@last_error,1;
END;
GO
CREATE PROCEDURE mixed_pilot.check_move
@source varchar(255),@target varchar(255),@tray varchar(255)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @floor int,@warehouse varchar(80),@src_lane varchar(80),@src_depth int,@dst_lane varchar(80),@dst_depth int,@dummy int;
-- Lock both positions before the Legacy routine changes source/target or creates a task.
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id IN(@source,@target);
SELECT @floor=the_floor,@warehouse=warehouse_id,@src_lane=group_coding_no,@src_depth=TRY_CONVERT(int,group_coding)
FROM dbo.bas_cargo_space WHERE cargo_space_id=@source AND status='2' AND locked='N' AND tray_code=@tray;
IF @floor IS NULL OR @source=@target OR NULLIF(@tray,'') IS NULL
THROW 51111,N'移库起点、托盘或货位状态不一致,未创建移库任务。',1;
IF EXISTS(SELECT 1 FROM dbo.mydo_job WITH(UPDLOCK,HOLDLOCK) WHERE tray_code=@tray OR cargo_space_id IN(@source,@target) OR target_cargo_space_id IN(@source,@target))
THROW 51112,N'移库起终点已有任务,未重复创建移库。',1;
IF NOT EXISTS(SELECT 1 FROM dbo.bas_stock WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id=@source AND tray_code=@tray)
OR EXISTS(SELECT 1 FROM dbo.bas_stock WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id=@source AND (tray_code<>@tray OR tray_code IS NULL OR ISNULL(out_quantity,0)<>0))
THROW 51113,N'移库源库存不一致或已分配,未创建移库任务。',1;
SELECT @dst_lane=group_coding_no,@dst_depth=TRY_CONVERT(int,group_coding)
FROM dbo.bas_cargo_space WHERE cargo_space_id=@target AND warehouse_id=@warehouse AND the_floor=@floor
AND this_cargo_area='E' AND status='1' AND locked='N' AND NULLIF(tray_code,'') IS NULL;
IF @dst_lane IS NULL OR @dst_depth IS NULL OR EXISTS(SELECT 1 FROM dbo.bas_stock WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id=@target)
THROW 51114,N'同层移库目标不可用,未创建移库任务。',1;
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space WITH(UPDLOCK,HOLDLOCK)
WHERE warehouse_id=@warehouse AND group_coding_no IN(@src_lane,@dst_lane);
IF EXISTS(SELECT 1 FROM dbo.bas_cargo_space b WITH(UPDLOCK,HOLDLOCK) WHERE b.warehouse_id=@warehouse
AND ((b.group_coding_no=@src_lane AND TRY_CONVERT(int,b.group_coding)>@src_depth)
OR (b.group_coding_no=@dst_lane AND TRY_CONVERT(int,b.group_coding)>@dst_depth))
AND (b.status<>'1' OR b.locked<>'N' OR NULLIF(b.tray_code,'') IS NOT NULL
OR EXISTS(SELECT 1 FROM dbo.bas_stock x WITH(UPDLOCK,HOLDLOCK) WHERE x.cargo_space_id=b.cargo_space_id)
OR EXISTS(SELECT 1 FROM dbo.mydo_job x WITH(UPDLOCK,HOLDLOCK) WHERE x.cargo_space_id=b.cargo_space_id OR x.target_cargo_space_id=b.cargo_space_id)))
THROW 51115,N'移库路径外侧被占用,未创建移库任务。',1;
END;

View File

@@ -0,0 +1,121 @@
param(
[string]$Server = 'lpc:.',
[Parameter(Mandatory=$true)][string]$FixtureJson,
[switch]$Baseline
)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
# Synthetic rows only. Never run fixtures in a production database.
$testDb = 'FT546WMS_MixedPilot_Test_' + [Guid]::NewGuid().ToString('N')
$cn = [Data.SqlClient.SqlConnection]::new("Server=$Server;Database=master;Integrated Security=True;Connect Timeout=8;Application Name=FT546MixedPilotTests")
$cn.Open()
function Exec([string]$sql) {
$c=$cn.CreateCommand(); $c.CommandTimeout=15
$c.CommandText=$sql
try { [void]$c.ExecuteNonQuery() } finally {$c.Dispose()}
}
function Scalar([string]$sql) {
$c=$cn.CreateCommand(); $c.CommandTimeout=15; $c.CommandText=$sql
try { $c.ExecuteScalar() } finally {$c.Dispose()}
}
function BatchFile([string]$file) {
foreach($batch in ([IO.File]::ReadAllText($file) -split '(?im)^\s*GO\s*\r?$')) {
if($batch.Trim()) { Exec $batch }
}
}
function Assert([bool]$ok,[string]$name) { if(-not $ok){throw "FAIL: $name"}; Write-Output "PASS: $name" }
$created=$false
try {
Exec "CREATE DATABASE [$testDb] ON PRIMARY (NAME=N'$testDb', FILENAME=N'$(Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultDataPath'))")$testDb.mdf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB) LOG ON (NAME=N'${testDb}_log', FILENAME=N'$(Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultLogPath'))")${testDb}_log.ldf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB)"
$created=$true; $cn.ChangeDatabase($testDb)
Exec 'SET NOCOUNT ON; SET LOCK_TIMEOUT 2000; SET DEADLOCK_PRIORITY LOW;'
$fixture=Get-Content -LiteralPath $FixtureJson -Raw -Encoding UTF8 | ConvertFrom-Json
$allowed=@('bas_cargo_space','bas_stock','mydo_job','mydo_job_detail','mydo_job_finish','bas_led','move_msg','out_stockout','out_stockout_detail')
foreach($table in ($fixture.columns|Where-Object table_name -in $allowed|Group-Object table_name)) {
$defs=@(foreach($c in $table.Group){
$type=$c.type_name
if($type -in @('varchar','char','nvarchar','nchar','varbinary','binary')) {
$size=if($c.max_length -eq -1){'max'}elseif($type.StartsWith('n')){[string]($c.max_length/2)}else{[string]$c.max_length}
$type+="($size)"
} elseif($type -in @('decimal','numeric')) {$type+="($($c.precision),$($c.scale))"}
# Nullability is relaxed for concise synthetic fixtures, types/order match site.
"[$($c.column_name)] $type NULL"
})
Exec "CREATE TABLE dbo.[$($table.Name)] ($($defs -join ','))"
}
foreach($p in $fixture.wms_objects|Where-Object name -in @('cargo_space_group_count','proc_allot_space','proc_yk_job_add','proc_yk_complete','proc_out_auto_allocation','proc_out_hand_allocation')) {Exec $p.definition}
Exec "CREATE TABLE dbo.bas_roadway(roadway_id varchar(80),out_locked varchar(10),out_locked2 varchar(10))"
Exec "CREATE PROCEDURE dbo.util_serial_job @kind varchar(10),@id varchar(255) OUTPUT AS SET @id=CONVERT(varchar(36),NEWID())"
Exec "CREATE SEQUENCE dbo.test_task_seq AS int START WITH 900001"
Exec "CREATE PROCEDURE dbo.util_serial_task @kind varchar(10),@id int OUTPUT AS SET @id=NEXT VALUE FOR dbo.test_task_seq"
if(-not $Baseline){ & "$PSScriptRoot/install-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase }
BatchFile "$PSScriptRoot/tests/seed.sql"
if($Baseline){
$result=Scalar "DECLARE @r int,@m varchar(255); EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT; SELECT @r"
Assert ($result -eq 10) 'new project gets designated front slot when Legacy has no position'
} else {
& "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode enable -ConfirmPhysicalFit -AllProjectsMayMix
Assert ((Scalar 'SELECT COUNT(*) FROM mixed_pilot.lane WHERE enabled=1') -eq 3) 'enable command opens exactly three whole channels'
& "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode disable
$refused=$false
try { & "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode uninstall } catch {$refused=$_.Exception.Message -like '*not drained*'}
Assert $refused 'uninstall refuses occupied trial channels'
BatchFile "$PSScriptRoot/tests/behavior.sql"
Write-Output 'PASS: mixed-pilot SQL behavior suite'
Exec 'DELETE dbo.mydo_job; DELETE dbo.mydo_job_detail; DELETE dbo.bas_stock; DELETE dbo.bas_cargo_space; DELETE mixed_pilot.dispatch_hold; DELETE mixed_pilot.move_audit; DELETE mixed_pilot.admission;'
BatchFile "$PSScriptRoot/tests/seed.sql"
BatchFile "$PSScriptRoot/tests/legacy-outbound.sql"
Write-Output 'PASS: actual site Legacy outbound procedure generates blocker relocation and holds rear outbound'
Exec 'DELETE dbo.mydo_job; DELETE dbo.mydo_job_detail; DELETE dbo.bas_stock; DELETE dbo.bas_cargo_space; DELETE mixed_pilot.dispatch_hold; DELETE mixed_pilot.move_audit; DELETE mixed_pilot.admission;'
BatchFile "$PSScriptRoot/tests/seed.sql"
Exec "UPDATE mixed_pilot.control SET enabled=1,all_projects_may_mix=1; UPDATE mixed_pilot.lane SET enabled=CASE WHEN lane_code='C1203' THEN 1 ELSE 0 END,physical_fit_confirmed=1; INSERT dbo.mydo_job(job_id,tray_code,warehouse_id,line_id,in_or_out,status,fault_flag) VALUES('IN-RACE','RACE-TRAY','01','RACE',1,'1','0'); INSERT dbo.mydo_job_detail(job_id,spare4,spare1,spare2,spare3,in_quantity) VALUES('IN-RACE','RACE','X','Y','Z',1)"
$raceCn=[Data.SqlClient.SqlConnection]::new("Server=$Server;Database=$testDb;Integrated Security=True;Connect Timeout=8;Application Name=FT546MixedPilotRaceTest")
$raceCn.Open()
try {
Exec 'BEGIN TRANSACTION;'
Exec "DECLARE @r int,@m varchar(255); EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT; IF @r<>10 THROW 51950,'First racing allocation failed',1;"
$raceCmd=$raceCn.CreateCommand();$raceCmd.CommandTimeout=10
$raceCmd.CommandText="DECLARE @r int,@m varchar(255); EXEC dbo.proc_allot_space 'RACE-TRAY','TEST',@r OUTPUT,@m OUTPUT; SELECT @r;"
$pending=$raceCmd.ExecuteScalarAsync()
# Hold the first reservation across the second request's lock timeout.
$second=$pending.GetAwaiter().GetResult()
Assert ($second -ne 10) 'concurrent request cannot allocate a reserved pilot slot'
Exec 'COMMIT TRANSACTION;'
Assert ((Scalar "SELECT COUNT(*) FROM dbo.mydo_job WHERE cargo_space_id='120703'") -eq 1) 'only one inbound task owns the pilot slot'
} finally {
if((Scalar 'SELECT @@TRANCOUNT') -gt 0){Exec 'ROLLBACK TRANSACTION;'}
if($raceCmd){$raceCmd.Dispose()};$raceCn.Dispose()
}
# Re-running install must preserve runtime switches and history.
Exec 'UPDATE mixed_pilot.control SET enabled=1 WHERE id=1'
& "$PSScriptRoot/install-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase
Assert ((Scalar 'SELECT enabled FROM mixed_pilot.control WHERE id=1') -eq 1) 'repeat install preserves switch'
$releaseDefinition=Scalar "SELECT OBJECT_DEFINITION(OBJECT_ID('mixed_pilot.release_ready_jobs'))"
Exec 'ALTER PROCEDURE mixed_pilot.release_ready_jobs AS RETURN;'
$driftRejected=$false
try { & "$PSScriptRoot/install-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase } catch {$driftRejected=$_.Exception.Message -like '*drift*'}
Assert $driftRejected 'repeat install detects altered pilot helpers'
Exec 'DROP PROCEDURE mixed_pilot.release_ready_jobs;'
Exec $releaseDefinition
& "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode disable
Exec 'DELETE dbo.mydo_job; DELETE dbo.bas_stock; UPDATE dbo.bas_cargo_space SET status=''1'',tray_code=NULL;'
& "$PSScriptRoot/control-mixed-pilot.ps1" -Server $Server -Database $testDb -TestDatabase -Mode uninstall
Assert ((Scalar 'SELECT installed FROM mixed_pilot.control WHERE id=1') -eq 0) 'uninstall removes runtime protection only after draining'
foreach($p in $fixture.wms_objects|Where-Object name -in @('proc_allot_space','proc_yk_job_add','proc_yk_complete')) {
$restored=Scalar "SELECT OBJECT_DEFINITION(OBJECT_ID('dbo.$($p.name)'))"
# SQL Server may persist CREATE even when ALTER was submitted. Compare the full body with only that keyword normalized.
$expected=[regex]::Replace($p.definition,'(?i)\b(?:CREATE|ALTER)\s+PROCEDURE','CREATE PROCEDURE')
$normalized=[regex]::Replace($restored,'(?i)\b(?:CREATE|ALTER)\s+PROCEDURE','CREATE PROCEDURE')
Assert ($normalized -ceq $expected) "uninstall restores $($p.name) body exactly"
}
}
} finally {
if($created){
[Data.SqlClient.SqlConnection]::ClearAllPools()
$cn.ChangeDatabase('master')
if($testDb -notmatch '^FT546WMS_MixedPilot_Test_[a-f0-9]{32}$'){throw 'Invalid scratch database name'}
Exec "DROP DATABASE [$testDb]"
Write-Output "Scratch database removed: $testDb"
}
$cn.Dispose()
}

View File

@@ -0,0 +1,138 @@
SET NOCOUNT ON;
DECLARE @r int,@m varchar(255),@job varchar(255),@n int;
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
IF @r<>2 OR EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='IN-NEW' AND cargo_space_id IS NOT NULL)
THROW 51900,'Disabled pilot must preserve Legacy no-position behavior',1;
UPDATE mixed_pilot.control SET enabled=1,all_projects_may_mix=1 WHERE id=1;
UPDATE mixed_pilot.lane SET enabled=1,physical_fit_confirmed=1 WHERE lane_code='C1203';
-- An ordinary same-project position must win before mixed fallback.
INSERT dbo.bas_cargo_space(cargo_space_id,warehouse_id,the_floor,the_platoon,the_column,group_coding_no,group_coding,this_cargo_area,status,locked,tray_code)
VALUES('110801','01',1,11,8,'C1101','1','C','2','N','SAME'),('110701','01',1,11,7,'C1101','2','C','1','N',NULL);
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,spare1,spare2,spare3,quantity,out_quantity)
VALUES('SAME-S','110801','SAME','NEW','D','E','F',1,0);
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
IF @r<>10 OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='IN-NEW' AND cargo_space_id='110701')
THROW 51901,'Normal Legacy allocation must have priority',1;
UPDATE dbo.mydo_job SET cargo_space_id=NULL,status='1' WHERE job_id='IN-NEW';
DELETE dbo.bas_stock WHERE stock_id='SAME-S';
DELETE dbo.bas_cargo_space WHERE group_coding_no='C1101';
-- No physical confirmation, missing E capacity, ambiguity and common-project input fail closed.
UPDATE mixed_pilot.lane SET physical_fit_confirmed=0 WHERE lane_code='C1203';
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
IF @r=10 THROW 51902,'Unconfirmed physical fit must not admit mixed pallet',1;
UPDATE mixed_pilot.lane SET physical_fit_confirmed=1 WHERE lane_code='C1203';
UPDATE dbo.bas_cargo_space SET locked='Y' WHERE this_cargo_area='E';
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
IF @r=10 THROW 51903,'No same-floor E capacity must not admit mixed pallet',1;
UPDATE dbo.bas_cargo_space SET locked='N' WHERE this_cargo_area='E';
UPDATE dbo.mydo_job_detail SET spare4='WRONG' WHERE job_id='IN-NEW';
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
IF @r=10 THROW 51904,'Header/detail project mismatch must be rejected',1;
UPDATE dbo.mydo_job_detail SET spare4='NEW' WHERE job_id='IN-NEW';
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
IF @r<>10 OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='IN-NEW' AND cargo_space_id='120703' AND status='3')
THROW 51905,'Fallback must reserve only the designated outer position',1;
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id='120703' AND status='3')
THROW 51906,'Fallback must reserve slot and job together',1;
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
IF @r<>10 OR (SELECT COUNT(*) FROM mixed_pilot.admission)<>1
THROW 51907,'Repeated allocation must return same reservation without a second admission',1;
INSERT dbo.mydo_job(job_id,tray_code,warehouse_id,line_id,in_or_out,status,execution_phase,fault_flag)
VALUES('IN-THIRD','THIRD-TRAY','01','THIRD',1,'1','0','0');
INSERT dbo.mydo_job_detail(job_detail_id,job_id,spare4,spare1,spare2,spare3,in_quantity)
VALUES('IN-THIRD-1','IN-THIRD','THIRD','X','Y','Z',1);
EXEC dbo.proc_allot_space 'THIRD-TRAY','TEST',@r OUTPUT,@m OUTPUT;
IF @r=10 THROW 51908,'A third project or an already reserved front slot must be rejected',1;
-- Simulate a confirmed physical inbound completion (no WCS or HTTP is contacted).
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,spare1,spare2,spare3,quantity,out_quantity)
VALUES('GUEST-S','120703','NEW-TRAY','NEW','D','E','F',1,0);
UPDATE dbo.bas_cargo_space SET status='2',tray_code='NEW-TRAY' WHERE cargo_space_id='120703';
DELETE dbo.mydo_job WHERE job_id='IN-NEW';
-- Automatic allocation creates the blocker move before the rear job.
EXEC dbo.proc_yk_job_add '120703','130803','NEW-TRAY';
SELECT @job=job_id FROM dbo.mydo_job WHERE in_or_out=30 AND tray_code='NEW-TRAY';
UPDATE dbo.bas_cargo_space SET status='4' WHERE cargo_space_id='120803';
INSERT dbo.mydo_job(job_id,tray_code,cargo_space_id,class_id,in_or_out,status,execution_phase,task_no)
VALUES('OUT-REAR','RESIDENT','120803','3',21,'1','0',800001);
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR' AND status='8')
THROW 51909,'Blocked rear outbound must be held before the WMS scheduler can see it',1;
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.dispatch_hold WHERE job_id='OUT-REAR' AND released_at IS NULL)
THROW 51910,'Held job must be diagnosable',1;
-- Occupied or wrong-floor destinations must not create a move or change inventory.
BEGIN TRY
EXEC dbo.proc_yk_job_add '120703','020704','NEW-TRAY';
THROW 51911,'Wrong-floor move was accepted',1;
END TRY BEGIN CATCH IF ERROR_NUMBER()=51911 THROW; END CATCH;
IF (SELECT COUNT(*) FROM dbo.mydo_job WHERE in_or_out=30)<>1
THROW 51912,'Rejected relocation must leave no partial task',1;
EXEC dbo.proc_yk_job_add '120703','130803','NEW-TRAY';
IF @job IS NULL OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id=@job AND status='1')
THROW 51913,'Outer relocation must remain dispatchable',1;
EXEC dbo.proc_yk_job_add '120703','130803','NEW-TRAY';
IF (SELECT COUNT(*) FROM dbo.mydo_job WHERE in_or_out=30)<>1
THROW 51914,'Repeated identical relocation must not duplicate tasks',1;
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR' AND status='8')
THROW 51915,'Creating a relocation is not physical completion',1;
-- Switch OFF while pallets are mixed: no new admissions, protection must remain.
UPDATE mixed_pilot.control SET enabled=0 WHERE id=1;
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR' AND status='8')
THROW 51916,'Disabling admission must not release blocked outbound',1;
EXEC dbo.proc_yk_complete @job,@r OUTPUT;
IF @r<>10 OR NOT EXISTS(SELECT 1 FROM dbo.bas_stock WHERE stock_id='GUEST-S' AND cargo_space_id='130803' AND spare4='NEW')
THROW 51917,'Move completion must preserve guest project ownership',1;
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR' AND status='1')
THROW 51918,'Rear outbound must be released only after confirmed front clearance',1;
SELECT @n=COUNT(*) FROM dbo.move_msg;
EXEC dbo.proc_yk_complete @job,@r OUTPUT;
IF @r<>10 OR (SELECT COUNT(*) FROM dbo.move_msg)<>@n
THROW 51919,'Duplicate move completion must not change stock or emit duplicate move messages',1;
-- Ordinary front outbound also blocks the rear until BOTH stock and job are cleared.
DELETE dbo.mydo_job WHERE job_id='OUT-REAR';
UPDATE dbo.bas_stock SET cargo_space_id='120703' WHERE stock_id='GUEST-S';
UPDATE dbo.bas_cargo_space SET status='2',tray_code='NEW-TRAY' WHERE cargo_space_id='120703';
INSERT dbo.mydo_job(job_id,tray_code,cargo_space_id,class_id,in_or_out,status,execution_phase,task_no)
VALUES('OUT-FRONT','NEW-TRAY','120703','3',21,'1','0',800002),('OUT-REAR2','RESIDENT','120803','3',21,'1','0',800003);
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-FRONT' AND status='1') OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR2' AND status='8')
THROW 51920,'Front may dispatch while blocked rear must wait',1;
UPDATE dbo.bas_cargo_space SET status='1',tray_code=NULL WHERE cargo_space_id='120703';
DELETE dbo.bas_stock WHERE stock_id='GUEST-S';
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR2' AND status='8')
THROW 51921,'An active front task must continue to block rear dispatch',1;
DELETE dbo.mydo_job WHERE job_id='OUT-FRONT';
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OUT-REAR2' AND status='1')
THROW 51922,'Final front task deletion must release waiting rear job',1;
-- Unlisted lanes must not be held by the pilot triggers.
INSERT dbo.mydo_job(job_id,tray_code,cargo_space_id,class_id,in_or_out,status,execution_phase,task_no)
VALUES('OTHER-OUT','OTHER-RESIDENT','080801','1',21,'1','0',800004);
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='OTHER-OUT' AND status='1')
THROW 51923,'Unlisted lanes must retain Legacy dispatch behavior',1;
-- Manual allocation must not strand a held task when no blocker move exists.
DELETE dbo.mydo_job WHERE job_id='OUT-REAR2';
UPDATE dbo.bas_cargo_space SET status='2',tray_code='NEW-TRAY' WHERE cargo_space_id='120703';
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,quantity,out_quantity)
VALUES('GUEST-AGAIN','120703','NEW-TRAY','NEW',1,0);
BEGIN TRY
INSERT dbo.mydo_job(job_id,tray_code,cargo_space_id,class_id,in_or_out,status,execution_phase,task_no)
VALUES('MANUAL-BLOCKED','RESIDENT','120803','3',21,'1','0',800005);
THROW 51924,'Manual blocked outbound was accepted without a blocker task',1;
END TRY BEGIN CATCH IF ERROR_NUMBER()=51924 THROW; END CATCH;
IF EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id='MANUAL-BLOCKED')
THROW 51925,'Rejected manual allocation must not leave a stranded held job',1;
BEGIN TRY
EXEC dbo.proc_yk_job_add '080801','130803','OTHER-RESIDENT';
THROW 51926,'Unlisted source overwrote a protected relocation destination',1;
END TRY BEGIN CATCH IF ERROR_NUMBER()=51926 THROW; END CATCH;
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id='130803' AND status='2' AND tray_code='NEW-TRAY')
THROW 51927,'Rejected stale relocation target must retain existing occupancy',1;
PRINT 'PASS: mixed-pilot sequential behavior assertions';

View File

@@ -0,0 +1,29 @@
SET NOCOUNT ON;
UPDATE mixed_pilot.control SET enabled=1,all_projects_may_mix=1;
UPDATE mixed_pilot.lane SET enabled=CASE WHEN lane_code='C1203' THEN 1 ELSE 0 END,protected=1,physical_fit_confirmed=1;
DECLARE @r int,@m varchar(255),@outResult varchar(100),@move varchar(255),@rear varchar(255);
EXEC dbo.proc_allot_space 'NEW-TRAY','TEST',@r OUTPUT,@m OUTPUT;
IF @r<>10 THROW 51930,'Failed to prepare genuine Legacy outbound fixture',1;
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,spare1,spare2,spare3,product_id,quantity,out_quantity,in_date)
VALUES('GUEST-S','120703','NEW-TRAY','NEW','D','E','F','P-GUEST',1,0,GETDATE());
UPDATE dbo.bas_cargo_space SET status='2',tray_code='NEW-TRAY' WHERE cargo_space_id='120703';
DELETE dbo.mydo_job WHERE job_id='IN-NEW';
UPDATE dbo.bas_stock SET product_id='P-REAR',in_date=GETDATE() WHERE stock_id='S1';
INSERT dbo.out_stockout(stockout_id,warehouse_id,stockout_source,fid,spare6,spare8,out_type)
VALUES('ORDER1','01','0','1','1','2','21');
INSERT dbo.out_stockout_detail(stockout_id,stockout_detail_id,product_id,spare1,spare2,spare3,spare4,quantity,allot_quantity)
VALUES('ORDER1','ORDER1-1','P-REAR','A','B','C','OLD',1,0);
EXEC dbo.proc_out_auto_allocation 'ORDER1-1',@outResult OUTPUT,@m OUTPUT;
IF @outResult<>'10' BEGIN DECLARE @failure nvarchar(2048)=N'Legacy auto allocation failed: '+ISNULL(@m,'NULL'); THROW 51931,@failure,1; END;
SELECT @move=job_id FROM dbo.mydo_job WHERE in_or_out=30 AND tray_code='NEW-TRAY';
SELECT @rear=job_id FROM dbo.mydo_job WHERE in_or_out=21 AND tray_code='RESIDENT';
IF @move IS NULL OR @rear IS NULL THROW 51932,'Actual Legacy must create both correct tray tasks',1;
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id=@move AND cargo_space_id='120703' AND status='1')
THROW 51933,'Legacy must move the outer guest, not the resident',1;
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id=@rear AND cargo_space_id='120803' AND status='8')
THROW 51934,'Legacy rear task must be invisible to the status=1 dispatch scheduler',1;
EXEC dbo.proc_yk_complete @move,@r OUTPUT;
IF @r<>10 OR NOT EXISTS(SELECT 1 FROM dbo.mydo_job WHERE job_id=@rear AND status='1')
THROW 51935,'Actual Legacy relocation completion must release the rear task',1;
IF NOT EXISTS(SELECT 1 FROM dbo.bas_stock WHERE stock_id='S1' AND spare4='OLD' AND out_quantity=1)
THROW 51936,'Resident allocation and project must retain Legacy values',1;

View File

@@ -0,0 +1,22 @@
INSERT dbo.bas_cargo_space(cargo_space_id,warehouse_id,the_floor,the_platoon,the_column,group_coding_no,group_coding,this_cargo_area,status,locked,tray_code)
VALUES ('120803','01',3,12,8,'C1203','1','C','2','N','RESIDENT'),
('120703','01',3,12,7,'C1203','2','C','1','N',NULL),
('020804','01',4,2,8,'C0204','1','C','2','N','RESIDENT4'),
('020704','01',4,2,7,'C0204','2','C','1','N',NULL),
('020805','01',5,2,8,'C0205','1','C','2','N','RESIDENT5'),
('020705','01',5,2,7,'C0205','2','C','1','N',NULL),
('130803','01',3,13,8,'C1303','1','E','1','N',NULL),
('130703','01',3,13,7,'C1303','2','E','1','N',NULL),
('140803','01',3,14,8,'C1403','1','E','1','N',NULL),
('140703','01',3,14,7,'C1403','2','E','1','N',NULL),
('080801','01',1,8,8,'C0801','1','C','2','N','OTHER-RESIDENT'),
('080701','01',1,8,7,'C0801','2','C','1','N',NULL);
INSERT dbo.bas_stock(stock_id,cargo_space_id,tray_code,spare4,spare1,spare2,spare3,quantity,out_quantity)
VALUES ('S1','120803','RESIDENT','OLD','A','B','C',10,0),
('S4','020804','RESIDENT4','OLD4','A','B','C',10,0),
('S5','020805','RESIDENT5','OLD5','A','B','C',10,0),
('SX','080801','OTHER-RESIDENT','OLD-X','A','B','C',10,0);
INSERT dbo.mydo_job(job_id,tray_code,warehouse_id,line_id,in_or_out,status,execution_phase,fault_flag)
VALUES ('IN-NEW','NEW-TRAY','01','NEW',1,'1','0','0');
INSERT dbo.mydo_job_detail(job_detail_id,job_id,spare4,spare1,spare2,spare3,product_id,in_quantity)
VALUES ('IN-NEW-1','IN-NEW','NEW','D','E','F','P1',1);

View File

@@ -0,0 +1,112 @@
ALTER PROCEDURE dbo.proc_allot_space @v_tray_code varchar(80),@v_code varchar(80),@v_result int OUTPUT,@v_resultmsg varchar(255) OUTPUT AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.control WHERE enabled=1) AND NOT EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE protected=1)
BEGIN
EXEC mixed_pilot.original_allot_space @v_tray_code,@v_code,@v_result OUTPUT,@v_resultmsg OUTPUT;
RETURN;
END;
SET XACT_ABORT OFF;
DECLARE @own bit=CASE WHEN @@TRANCOUNT=0 THEN 1 ELSE 0 END,@lock int,@dummy int,@job varchar(255),@slot varchar(80);
IF @own=1 BEGIN TRANSACTION; ELSE SAVE TRANSACTION mp_inbound;
BEGIN TRY
EXEC @lock=sys.sp_getapplock @Resource='FT546.mixed_pilot.inbound',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=500;
IF @lock<0 THROW 51120,N'试点分配正在处理,请稍后重试。',1;
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space s WITH(UPDLOCK,HOLDLOCK)
JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no WHERE l.enabled=1 OR l.protected=1;
SELECT @dummy=COUNT(*) FROM dbo.mydo_job WITH(UPDLOCK,HOLDLOCK) WHERE tray_code=@v_tray_code;
IF @dummy<>1 THROW 51121,N'托盘任务不存在或不唯一,请核对后重试。',1;
SELECT @job=job_id,@slot=NULLIF(cargo_space_id,'') FROM dbo.mydo_job WHERE tray_code=@v_tray_code AND in_or_out<20;
IF @job IS NULL THROW 51122,N'没有对应的入库任务。',1;
IF @slot IS NOT NULL
BEGIN
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@slot AND status='3')
THROW 51123,N'已有入库分配的货位状态不一致,请核对任务。',1;
SET @v_result=10; SET @v_resultmsg='成功';
END
ELSE EXEC mixed_pilot.allot_space_with_fallback @v_tray_code,@v_code,@v_result OUTPUT,@v_resultmsg OUTPUT;
IF @own=1 COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @own=1 AND XACT_STATE()<>0 ROLLBACK TRANSACTION;
ELSE IF XACT_STATE()=1 ROLLBACK TRANSACTION mp_inbound;
ELSE IF XACT_STATE()=-1 THROW;
SET @v_result=2; SET @v_resultmsg=CONVERT(varchar(255),ERROR_MESSAGE());
END CATCH;
END;
GO
ALTER PROCEDURE dbo.proc_yk_job_add @v_cargo_space_id varchar(255),@v_tar_cargo_space_id varchar(255),@v_tray_code varchar(255) AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.lane WHERE enabled=1 OR protected=1)
BEGIN
EXEC mixed_pilot.original_yk_job_add @v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code;
RETURN;
END;
DECLARE @own bit=CASE WHEN @@TRANCOUNT=0 THEN 1 ELSE 0 END,@job varchar(255),@dummy int;
IF @own=1 BEGIN TRANSACTION; ELSE SAVE TRANSACTION mp_move;
BEGIN TRY
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id IN(@v_cargo_space_id,@v_tar_cargo_space_id);
IF NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space s JOIN mixed_pilot.lane l ON l.warehouse_id=s.warehouse_id AND l.lane_code=s.group_coding_no
WHERE s.cargo_space_id IN(@v_cargo_space_id,@v_tar_cargo_space_id) AND (l.protected=1 OR l.enabled=1))
AND NOT EXISTS(SELECT 1 FROM mixed_pilot.move_audit WHERE target_slot=@v_tar_cargo_space_id)
BEGIN
EXEC mixed_pilot.original_yk_job_add @v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code;
IF @own=1 COMMIT TRANSACTION;
RETURN;
END;
IF EXISTS(SELECT 1 FROM mixed_pilot.move_audit a JOIN dbo.mydo_job j ON j.job_id=a.job_id
WHERE a.source_slot=@v_cargo_space_id AND a.target_slot=@v_tar_cargo_space_id AND a.tray_code=@v_tray_code AND a.completed_at IS NULL
AND j.in_or_out=30 AND j.cargo_space_id=a.source_slot AND j.target_cargo_space_id=a.target_slot AND j.tray_code=a.tray_code)
BEGIN
IF @own=1 COMMIT TRANSACTION;
RETURN;
END;
EXEC mixed_pilot.check_move @v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code;
EXEC mixed_pilot.original_yk_job_add @v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code;
SELECT @job=job_id FROM dbo.mydo_job WHERE in_or_out=30 AND cargo_space_id=@v_cargo_space_id AND target_cargo_space_id=@v_tar_cargo_space_id AND tray_code=@v_tray_code;
IF @job IS NULL THROW 51124,N'未能建立可核验的移库任务。',1;
INSERT mixed_pilot.move_audit(job_id,source_slot,target_slot,tray_code) VALUES(@job,@v_cargo_space_id,@v_tar_cargo_space_id,@v_tray_code);
IF @own=1 COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @own=1 AND XACT_STATE()<>0 ROLLBACK TRANSACTION;
ELSE IF XACT_STATE()=1 ROLLBACK TRANSACTION mp_move;
THROW;
END CATCH;
END;
GO
ALTER PROCEDURE dbo.proc_yk_complete @v_job_id varchar(255),@v_result int OUTPUT AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS(SELECT 1 FROM mixed_pilot.move_audit WHERE job_id=@v_job_id)
BEGIN
EXEC mixed_pilot.original_yk_complete @v_job_id,@v_result OUTPUT;
RETURN;
END;
DECLARE @own bit=CASE WHEN @@TRANCOUNT=0 THEN 1 ELSE 0 END,@src varchar(255),@dst varchar(255),@tray varchar(255),@done datetime2,@dummy int;
IF @own=1 BEGIN TRANSACTION; ELSE SAVE TRANSACTION mp_complete;
BEGIN TRY
SELECT @src=source_slot,@dst=target_slot,@tray=tray_code,@done=completed_at FROM mixed_pilot.move_audit WITH(UPDLOCK,HOLDLOCK) WHERE job_id=@v_job_id;
IF @done IS NULL
BEGIN
SELECT @dummy=COUNT(*) FROM dbo.bas_cargo_space WITH(UPDLOCK,HOLDLOCK) WHERE cargo_space_id IN(@src,@dst);
IF NOT EXISTS(SELECT 1 FROM dbo.mydo_job WITH(UPDLOCK,HOLDLOCK) WHERE job_id=@v_job_id AND in_or_out=30 AND tray_code=@tray AND cargo_space_id=@src AND target_cargo_space_id=@dst)
OR NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@src AND status='4' AND tray_code=@tray)
OR NOT EXISTS(SELECT 1 FROM dbo.bas_cargo_space WHERE cargo_space_id=@dst AND status='3' AND NULLIF(tray_code,'') IS NULL)
OR EXISTS(SELECT 1 FROM dbo.bas_stock WHERE cargo_space_id=@dst)
OR NOT EXISTS(SELECT 1 FROM dbo.bas_stock WHERE cargo_space_id=@src AND tray_code=@tray)
OR EXISTS(SELECT 1 FROM dbo.bas_stock WHERE cargo_space_id=@src AND (tray_code<>@tray OR tray_code IS NULL))
THROW 51125,N'移库完成的起终点或库存不一致,未修改库存。',1;
EXEC mixed_pilot.original_yk_complete @v_job_id,@v_result OUTPUT;
UPDATE mixed_pilot.move_audit SET completed_at=SYSUTCDATETIME() WHERE job_id=@v_job_id;
END;
SET @v_result=10;
IF @own=1 COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @own=1 AND XACT_STATE()<>0 ROLLBACK TRANSACTION;
ELSE IF XACT_STATE()=1 ROLLBACK TRANSACTION mp_complete;
THROW;
END CATCH;
END;

View File

@@ -0,0 +1,57 @@
# 影子通道汇总锁定值修复(2026-09-14)
现场 `locked=N` 表示未锁定;旧 `alg.usp_rebuild_lane_state` 仅接受 NULL/空白,导致通道可用数量归零。补丁让它与现有货位视图一致,接受 NULL、空白、`0`、`N`。其他占用、库存、角色、预留、出库与空洞限制全部保留。两个完整安装 SQL 也同步修正,避免以后安装重新引入问题。
## 对正在运行的业务有何影响
影子读取生产 `dbo` 数据,生成 `alg.*` 快照,再规划和记录比较结果。此次只 ALTER `alg.usp_rebuild_lane_state`,其运行结果写入 `alg.lane_state`。不修改生产货位的锁定值、货位状态、库存、订单或 Legacy 分配过程,不切换 ACTIVE,不需要替换 JAR 或重启 WMS/WCS。
现有三通道混放试点是另外一套已批准的业务规则,本补丁不更改其范围或开关。共用 SQL Server 仍会共享 CPU、I/O、内存及锁资源,不能承诺性能影响为零;补丁使用 1.5 秒锁等待上限,等待失败退出,不结束其他会话。
## 从 CMD 操作
在本目录执行,使用 Windows 集成认证;账号需要读取过程定义及 ALTER 此过程的权限。默认数据库仅支持 `FT546-wms`。
```bat
repair-shadow-lane-lock.cmd -Mode Check
```
记录返回的 `sha256`。确认状态为 `NEEDS_FIX` 后,将下面的占位文字换成该值;备份文件使用全新路径(不能覆盖已有文件):
```bat
repair-shadow-lane-lock.cmd -Mode Apply -ExpectedSha256 检查返回的sha256 -BackupFile "C:\project\FT546WMS-shadow-lane-lock-20260914\original-procedure.json"
```
返回 `status=OK` 后,等待现有影子流程在业务退出后的安静窗口正常刷新;它不是固定每隔几分钟必刷。不要手动重建正在使用的快照,也不要为此运行完整 `shadow-v3-install.cmd`。补丁不改整包版本标识,用过程哈希确认状态。`ALREADY_FIXED` 表示无需重复更新;hash mismatch 表示现场定义有变化,应先核对。锁超时或失败时事务自动回滚,已有备份保留;重试采用新的备份文件名。
只读检查(SQL 查询窗口):
```sql
SELECT snapshot_generation,snapshot_status,synchronized_at,last_error
FROM alg.shadow_snapshot_state WHERE warehouse_id='01';
SELECT COUNT(*) candidate_lanes,SUM(l.available_count) aggregated_available_spaces
FROM alg.lane_state l JOIN alg.shadow_snapshot_state p
ON p.warehouse_id=l.warehouse_id AND p.snapshot_generation=l.snapshot_generation
WHERE l.warehouse_id='01' AND p.snapshot_status='PUBLISHED'
AND l.available_count>0 AND l.pending_outbound=0 AND l.has_gap=0;
```
候选通道恢复不代表任意托盘一定能分配,也不代表完整算法可以接管。仍需新发生的真实入库/出库比较和混放移库闭环证据。
## 回退
重新 `Check`,将返回的当前 `sha256` 填入命令,使用 Apply 保存的原始备份。只恢复此过程原文及 ANSI/quoted 设置;有后续改动时会拒绝覆盖:
```bat
repair-shadow-lane-lock.cmd -Mode Rollback -ExpectedSha256 当前sha256 -BackupFile "C:\project\FT546WMS-shadow-lane-lock-20260914\original-procedure.json"
```
回退后下一次正常刷新会恢复旧汇总行为,包括旧的 `N` 误排除问题。既有影子记录不删除,业务数据不回退。
## 隔离回归
```bat
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts\shadow-lane-lock\test-shadow-lane-lock.ps1 -TestRepair
```
从项目根目录执行。自动创建随机名称 `FT546WMS_LaneLock_Test_<GUID>` 的小型测试库,只放人工数据;不会接收生产库作为测试目标。执行两个源 SQL 中真实的 rebuild 过程,指纹计算使用明确标注的测试桩。覆盖锁值、各排除规则、跨代次、跨仓库/通道、重复刷新、修复的精确范围、哈希拒绝、重复更新、DDL 失败回滚及恢复原文。退出时删除本次测试库;此测试不验证现场托盘的实际规划效果。

View File

@@ -0,0 +1,22 @@
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N' AND available_count=1)
THROW 51950,'locked=N must be available in the actual lane aggregate',1;
IF (SELECT COUNT(*) FROM alg.lane_state WHERE warehouse_id='01' AND lane_code LIKE 'UNLOCK_%' AND available_count=1 AND pending_outbound=0 AND has_gap=0 AND snapshot_generation=101)<>7
THROW 51950,'NULL, blank, 0, N, trimmed N, released reservation and old generation must remain eligible',1;
IF EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code NOT LIKE 'UNLOCK_%' AND available_count<>0)
THROW 51950,'locked/unknown/occupied/tray/stock/role/reservation/outbound/gap exclusions must remain',1;
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='OUTBOUND' AND pending_outbound=1 AND available_count=0)
THROW 51950,'outbound must exclude the entire lane',1;
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='GAP' AND has_gap=1 AND available_count=0)
THROW 51950,'gap must exclude the entire lane',1;
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N' AND space_count=1 AND occupied_count=0)
THROW 51950,'old generation must not pollute current counts',1;
DECLARE @stamp datetime2=(SELECT rebuilt_at FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N');
EXEC alg.usp_rebuild_lane_state @warehouse_id='01';
IF (SELECT rebuilt_at FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N')<>@stamp
THROW 51950,'unchanged repeat rebuild should not rewrite lane state',1;
EXEC alg.usp_rebuild_lane_state @warehouse_id='02';
EXEC alg.usp_rebuild_lane_state @warehouse_id='01',@lane_code='UNLOCK_N';
IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='02' AND lane_code='OTHER_WAREHOUSE' AND available_count=1 AND snapshot_generation=201)
THROW 51950,'scoped rebuild must preserve other warehouse',1;
IF (SELECT COUNT(*) FROM alg.lane_state WHERE warehouse_id='01')<>16
THROW 51950,'scoped rebuild must preserve other lanes',1;

View File

@@ -0,0 +1,43 @@
-- Only synthetic fixtures; this script is run exclusively in a randomly named scratch DB.
CREATE TABLE alg.cargo_space_snapshot(warehouse_id varchar(80),space_id varchar(80),lane_code varchar(80),depth_raw varchar(10),space_status varchar(10),locked varchar(10),tray_code varchar(80),snapshot_generation bigint);
CREATE TABLE alg.space_inventory_summary(warehouse_id varchar(80),space_id varchar(80),stock_detail_count int,snapshot_generation bigint);
CREATE TABLE alg.space_role(space_id varchar(80),role varchar(40));
CREATE TABLE alg.active_job_snapshot(warehouse_id varchar(80),source_space_id varchar(80),in_or_out varchar(10),snapshot_generation bigint);
CREATE TABLE alg.reservation(reservation_id varchar(80),space_id varchar(80),status varchar(20));
CREATE TABLE alg.shadow_snapshot_state(warehouse_id varchar(80),snapshot_generation bigint,snapshot_status varchar(20));
CREATE TABLE alg.lane_state(warehouse_id varchar(80),lane_code varchar(80),space_count int,occupied_count int,available_count int,pending_outbound bit,has_gap bit,semantic_fingerprint varbinary(32),snapshot_generation bigint,rebuilt_at datetime2 DEFAULT SYSUTCDATETIME(),PRIMARY KEY(warehouse_id,lane_code));
GO
-- Explicit test stub: fingerprint internals are outside this aggregation regression.
-- The actual rebuild procedure, cursor, guards and MERGE are always executed unmodified.
CREATE PROCEDURE alg.usp_compute_lane_semantic_fingerprint
@warehouse_id varchar(80),@lane_code varchar(80),@semantic_fingerprint varbinary(32) OUTPUT,
@inventory_source varchar(20),@snapshot_generation bigint
AS
SET @semantic_fingerprint=HASHBYTES('SHA2_256',@warehouse_id+':'+@lane_code+':'+CONVERT(varchar(30),@snapshot_generation));
GO
INSERT alg.shadow_snapshot_state VALUES('01',101,'PUBLISHED'),('02',201,'PUBLISHED');
INSERT alg.cargo_space_snapshot VALUES
('01','N','UNLOCK_N','1','1','N',NULL,101),
('01','ZERO','UNLOCK_ZERO','1','1','0',NULL,101),
('01','NULL','UNLOCK_NULL','1','1',NULL,NULL,101),
('01','BLANK','UNLOCK_BLANK','1','1',' ',NULL,101),
('01','TRIM','UNLOCK_TRIM','1','1',' N ',NULL,101),
('01','Y','LOCK_Y','1','1','Y',NULL,101),
('01','UNKNOWN','LOCK_UNKNOWN','1','1','?',NULL,101),
('01','STATUS','OCCUPIED','1','2','N',NULL,101),
('01','TRAY','HAS_TRAY','1','1','N','T1',101),
('01','STOCK','HAS_STOCK','1','1','N',NULL,101),
('01','ROLE','PROTECTED','1','1','N',NULL,101),
('01','RESERVED','RESERVED','1','1','N',NULL,101),
('01','RELEASED','UNLOCK_RELEASED','1','1','N',NULL,101),
('01','OUT1','OUTBOUND','1','1','N',NULL,101),
('01','OUT2','OUTBOUND','2','1','N',NULL,101),
('01','GAP1','GAP','1','1','N',NULL,101),
('01','GAP2','GAP','2','2','N','T2',101),
('01','HISTORY','UNLOCK_HISTORY','1','1','N',NULL,101),
('01','N','UNLOCK_N','1','2','Y','OLD',100),
('02','W2','OTHER_WAREHOUSE','1','1','N',NULL,201);
INSERT alg.space_inventory_summary VALUES('01','STOCK',1,101),('01','HISTORY',1,100);
INSERT alg.space_role VALUES('ROLE','BUFFER');
INSERT alg.reservation VALUES('R1','RESERVED','ACTIVE'),('R2','RELEASED','RELEASED');
INSERT alg.active_job_snapshot VALUES('01','OUT1','20',101),('01','HISTORY','20',100);

View File

@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0repair-shadow-lane-lock.ps1" %*
exit /b %errorlevel%

View File

@@ -0,0 +1,136 @@
param(
[string]$Server = 'lpc:.',
[string]$Database = 'FT546-wms',
[ValidateSet('Check','Apply','Rollback')][string]$Mode = 'Check',
[string]$ExpectedSha256,
[string]$BackupFile
)
$ErrorActionPreference='Stop'
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
if($Database -ne 'FT546-wms' -and $Database -notmatch '^FT546WMS_LaneLock_Test_[a-f0-9]{32}$') {
throw 'Only FT546-wms or the dedicated scratch database is supported'
}
if($Mode -ne 'Check' -and ($ExpectedSha256 -notmatch '^[a-fA-F0-9]{64}$' -or -not $BackupFile)) {
throw 'Apply/Rollback requires ExpectedSha256 and BackupFile'
}
$old="NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL"
$new="(NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))"
function Hash([string]$value) {
$sha=[Security.Cryptography.SHA256]::Create()
try { ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($value)))).Replace('-','').ToLowerInvariant() }
finally { $sha.Dispose() }
}
function Canonical([string]$value) {
if($value -notmatch '(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE\s+alg\.usp_rebuild_lane_state\b') {
throw 'Unexpected procedure header'
}
[regex]::Replace($value,'(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE','ALTER PROCEDURE')
}
$cs=[Data.SqlClient.SqlConnectionStringBuilder]::new()
$cs['Data Source']=$Server; $cs['Initial Catalog']=$Database; $cs['Integrated Security']=$true
$cs['Connect Timeout']=8; $cs['Application Name']='FT546ShadowLaneLockRepair'
$cn=[Data.SqlClient.SqlConnection]::new($cs.ConnectionString)
$transaction=$null
function Command([string]$sql) {
$cmd=$cn.CreateCommand(); $cmd.CommandTimeout=10; $cmd.CommandText=$sql
if($script:transaction){$cmd.Transaction=$script:transaction}
return $cmd
}
function Exec([string]$sql) {
$cmd=Command $sql
try { [void]$cmd.ExecuteNonQuery() } finally { $cmd.Dispose() }
}
function Scalar([string]$sql) {
$cmd=Command $sql
try { $cmd.ExecuteScalar() } finally { $cmd.Dispose() }
}
function ReadDefinition {
$cmd=Command @'
SELECT m.definition,m.uses_ansi_nulls,m.uses_quoted_identifier
FROM sys.sql_modules m JOIN sys.procedures p ON p.object_id=m.object_id
WHERE p.object_id=OBJECT_ID(N'alg.usp_rebuild_lane_state',N'P');
'@
try {
$reader=$cmd.ExecuteReader()
try {
if(-not $reader.Read() -or $reader.IsDBNull(0)){throw 'Procedure missing or encrypted'}
[pscustomobject]@{definition=$reader.GetString(0);ansi=$reader.GetBoolean(1);quoted=$reader.GetBoolean(2)}
} finally {$reader.Dispose()}
} finally {$cmd.Dispose()}
}
$cn.Open()
try {
Exec 'SET NOCOUNT ON; SET XACT_ABORT ON; SET LOCK_TIMEOUT 1500; SET DEADLOCK_PRIORITY LOW;'
$instance=[string](Scalar 'SELECT CONVERT(nvarchar(128),SERVERPROPERTY(''ServerName''))')
$before=ReadDefinition
$beforeHash=Hash $before.definition
if($ExpectedSha256 -and $beforeHash -ne $ExpectedSha256){throw 'Procedure hash mismatch; recapture and review before retrying'}
$null=Canonical $before.definition
$fixedCount=([regex]::Matches($before.definition,[regex]::Escape($new))).Count
$oldCount=([regex]::Matches($before.definition,[regex]::Escape($old))).Count
if($oldCount -ne 1 -or $fixedCount -gt 1){throw 'Unexpected lock predicate; no changes made'}
if($Mode -eq 'Check') {
[ordered]@{mode=$Mode;server=$instance;database=$Database;sha256=$beforeHash;state=$(if($fixedCount -eq 1){'ALREADY_FIXED'}else{'NEEDS_FIX'});ansi=$before.ansi;quoted=$before.quoted}|ConvertTo-Json -Compress
return
}
if($Mode -eq 'Apply' -and $fixedCount -eq 1) {
[ordered]@{mode=$Mode;status='ALREADY_FIXED';sha256=$beforeHash;database_written=$false}|ConvertTo-Json -Compress
return
}
if($Mode -eq 'Apply') {
$target=(Canonical $before.definition).Replace($old,$new)
$backup=[ordered]@{
format='FT546WMS_SHADOW_LANE_LOCK_20260914';server=$instance;database=$Database
utc=[DateTime]::UtcNow.ToString('o');definition=$before.definition;original_sha256=$beforeHash
fixed_canonical_sha256=(Hash $target);ansi=$before.ansi;quoted=$before.quoted
}
# Backup is durable before beginning DDL; no existing file can be overwritten.
$stream=[IO.File]::Open($BackupFile,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
try {
$bytes=[Text.Encoding]::UTF8.GetBytes(($backup|ConvertTo-Json -Depth 4))
$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)
} finally {$stream.Dispose()}
} else {
$backup=[IO.File]::ReadAllText($BackupFile)|ConvertFrom-Json
if($backup.format -ne 'FT546WMS_SHADOW_LANE_LOCK_20260914' -or $backup.server -cne $instance -or $backup.database -cne $Database) {
throw 'Backup identity mismatch'
}
if((Hash $backup.definition) -ne $backup.original_sha256){throw 'Backup integrity mismatch'}
if($fixedCount -ne 1 -or (Hash (Canonical $before.definition)) -ne $backup.fixed_canonical_sha256) {
throw 'Rollback refuses drift from the backed-up repair'
}
$target=Canonical $backup.definition
}
# Only the alg procedure is changed. No rebuild of the currently published generation,
# no dbo DML, no application restart, no full migration and no active session is killed.
$transaction=$cn.BeginTransaction()
$lockResult=Scalar "DECLARE @r int; EXEC @r=sys.sp_getapplock @Resource=N'FT546WMS_ShadowLaneLockRepair',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=0; SELECT @r;"
if($lockResult -lt 0){throw 'Another lane-lock repair is running'}
$recheck=ReadDefinition
if((Hash $recheck.definition) -ne $beforeHash -or $recheck.ansi -ne $before.ansi -or $recheck.quoted -ne $before.quoted) {
throw 'Procedure changed during preparation; no changes made'
}
# Refuse unreviewed database DDL hooks that could make ALTER write business data.
if((Scalar 'SELECT COUNT(*) FROM sys.triggers WHERE parent_class=0 AND is_disabled=0') -gt 0) {
throw 'Enabled database DDL triggers require review before this repair'
}
$ansi=if($backup.ansi){'ON'}else{'OFF'}
$quoted=if($backup.quoted){'ON'}else{'OFF'}
Exec "SET ANSI_NULLS $ansi; SET QUOTED_IDENTIFIER $quoted;"
$watch=[Diagnostics.Stopwatch]::StartNew()
Exec $target
$after=ReadDefinition
if((Canonical $after.definition) -cne $target -or $after.ansi -ne $backup.ansi -or $after.quoted -ne $backup.quoted) {
throw 'Definition verification failed; rolling back'
}
$transaction.Commit();$transaction.Dispose();$transaction=$null
$watch.Stop()
[ordered]@{
mode=$Mode;status='OK';server=$instance;database=$Database;object='alg.usp_rebuild_lane_state'
before_sha256=$beforeHash;after_sha256=(Hash $after.definition);ddl_ms=$watch.ElapsedMilliseconds
backup=$BackupFile;refresh_invoked=$false;utc=[DateTime]::UtcNow.ToString('o')
}|ConvertTo-Json -Compress
} finally {
if($transaction){try{$transaction.Rollback()}finally{$transaction.Dispose()}}
$cn.Dispose()
}

View File

@@ -0,0 +1,104 @@
param(
[string]$Server = 'lpc:.',
[string]$SqlDirectory = "$PSScriptRoot/../../fortucky-algorithm/src/main/resources/db/sqlserver",
[switch]$TestRepair
)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
# This runner accepts no database name: fixtures can only enter its own tiny scratch DB.
$testDb = 'FT546WMS_LaneLock_Test_' + [Guid]::NewGuid().ToString('N')
$cs = [Data.SqlClient.SqlConnectionStringBuilder]::new()
$cs['Data Source']=$Server; $cs['Initial Catalog']='master'; $cs['Integrated Security']=$true
$cs['Connect Timeout']=8; $cs['Application Name']='FT546ShadowLaneLockTests'
$cn = [Data.SqlClient.SqlConnection]::new($cs.ConnectionString)
function Exec([string]$sql) {
$cmd=$cn.CreateCommand(); $cmd.CommandTimeout=15; $cmd.CommandText=$sql
try { [void]$cmd.ExecuteNonQuery() } finally { $cmd.Dispose() }
}
function Scalar([string]$sql) {
$cmd=$cn.CreateCommand(); $cmd.CommandTimeout=15; $cmd.CommandText=$sql
try { $cmd.ExecuteScalar() } finally { $cmd.Dispose() }
}
function Assert([bool]$condition,[string]$message) {
if(-not $condition){throw "FAIL: $message"}
Write-Output "PASS: $message"
}
function Hash([string]$value) {
$sha=[Security.Cryptography.SHA256]::Create()
try { ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($value)))).Replace('-','').ToLowerInvariant() }
finally { $sha.Dispose() }
}
function Definition { [string](Scalar "SELECT OBJECT_DEFINITION(OBJECT_ID('alg.usp_rebuild_lane_state'))") }
function Canonical([string]$value) { [regex]::Replace($value,'(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE','ALTER PROCEDURE') }
$created=$false
$cn.Open()
try {
$dataPath=([string](Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultDataPath'))")).Replace("'","''")
$logPath=([string](Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultLogPath'))")).Replace("'","''")
Exec "CREATE DATABASE [$testDb] ON PRIMARY (NAME=N'$testDb',FILENAME=N'${dataPath}$testDb.mdf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB) LOG ON (NAME=N'${testDb}_log',FILENAME=N'${logPath}${testDb}_log.ldf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB)"
$created=$true; $cn.ChangeDatabase($testDb)
Exec 'SET NOCOUNT ON; SET LOCK_TIMEOUT 1500; SET DEADLOCK_PRIORITY LOW;'
Exec 'CREATE SCHEMA alg'
foreach($batch in ([IO.File]::ReadAllText("$PSScriptRoot/fixture.sql") -split '(?im)^\s*GO\s*\r?$')) {
if($batch.Trim()){ Exec $batch }
}
foreach($source in @('install-shadow.sql','upgrade-shadow-v2-to-v3.sql')) {
$text=[IO.File]::ReadAllText((Join-Path $SqlDirectory $source))
$bodies=@($text -split '(?im)^\s*GO\s*\r?$' | Where-Object {$_ -match '(?im)^\s*(CREATE|ALTER) PROCEDURE alg\.usp_rebuild_lane_state\b'})
Assert ($bodies.Count -eq 1) "$source has exactly one real rebuild body"
$body=$bodies[0]
$start=[regex]::Match($body,'(?im)^\s*(CREATE|ALTER) PROCEDURE alg\.usp_rebuild_lane_state\b').Index
$body=$body.Substring($start).Trim()
Exec "IF OBJECT_ID('alg.usp_rebuild_lane_state') IS NOT NULL DROP PROCEDURE alg.usp_rebuild_lane_state"
Exec ([regex]::Replace($body,'(?i)\A(CREATE|ALTER) PROCEDURE','CREATE PROCEDURE'))
Exec 'DELETE alg.lane_state'
Exec "EXEC alg.usp_rebuild_lane_state @warehouse_id='01'"
Exec ([IO.File]::ReadAllText("$PSScriptRoot/assertions.sql"))
Write-Output "PASS: $source actual T-SQL aggregation, exclusions, repeat and scoping"
if($TestRepair) {
$old="NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL"
$new="(NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))"
$original=(Canonical (Definition)).Replace($new,$old)
Exec $original
$before=Definition
$backup=Join-Path $PSScriptRoot "$testDb-$source.backup.json"
$repair=Join-Path $PSScriptRoot 'repair-shadow-lane-lock.ps1'
try {
$null=& $repair -Server $Server -Database $testDb -Mode Check -ExpectedSha256 (Hash $before)
Assert ((Definition) -ceq $before) 'check mode leaves procedure unchanged'
$null=& $repair -Server $Server -Database $testDb -Mode Apply -ExpectedSha256 (Hash $before) -BackupFile $backup
$fixed=Definition
Assert ((Canonical $fixed) -ceq (Canonical $before).Replace($old,$new)) 'repair changes only the lock predicate'
Exec "EXEC alg.usp_rebuild_lane_state @warehouse_id='01'"
Exec ([IO.File]::ReadAllText("$PSScriptRoot/assertions.sql"))
$null=& $repair -Server $Server -Database $testDb -Mode Apply -ExpectedSha256 (Hash $fixed) -BackupFile $backup
Assert ((Definition) -ceq $fixed) 'repeat apply is a no-op'
$refused=$false
try { $null=& $repair -Server $Server -Database $testDb -Mode Apply -ExpectedSha256 ('0'*64) -BackupFile $backup }
catch { $refused=$_.Exception.Message -like '*hash mismatch*' }
Assert $refused 'unexpected definition is refused'
# Invalid ALTER must roll back atomically, including connection transaction cleanup.
$tx=$cn.BeginTransaction()
$cmd=$cn.CreateCommand(); $cmd.Transaction=$tx; $cmd.CommandText='ALTER PROCEDURE alg.usp_rebuild_lane_state AS INVALID SQL HERE'
$failed=$false
try { [void]$cmd.ExecuteNonQuery() } catch { $failed=$true } finally { $tx.Rollback();$cmd.Dispose();$tx.Dispose() }
Assert ($failed -and (Definition) -ceq $fixed) 'failed ALTER preserves the published procedure'
$null=& $repair -Server $Server -Database $testDb -Mode Rollback -ExpectedSha256 (Hash $fixed) -BackupFile $backup
Assert ((Canonical (Definition)) -ceq (Canonical $before)) 'rollback restores the exact original body'
Exec "EXEC alg.usp_rebuild_lane_state @warehouse_id='01'"
Assert ((Scalar "SELECT available_count FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N'") -eq 0) 'rollback reproduces the original N exclusion'
} finally {
if(Test-Path -LiteralPath $backup){ Remove-Item -LiteralPath $backup }
}
}
}
} finally {
if($created) {
$cn.ChangeDatabase('master')
[Data.SqlClient.SqlConnection]::ClearAllPools()
if($testDb -notmatch '^FT546WMS_LaneLock_Test_[a-f0-9]{32}$'){throw 'Invalid scratch database name'}
Exec "DROP DATABASE [$testDb]"
Write-Output "Scratch database removed: $testDb"
}
$cn.Dispose()
}

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;