3 Commits

15 changed files with 368 additions and 11 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

@@ -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);
}
@@ -215,7 +217,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,13 +232,13 @@ 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);
});
@@ -349,7 +351,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 +359,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 +611,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

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

@@ -95,4 +95,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:

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(

View File

@@ -33,6 +33,7 @@ class ShadowConfigurationTest {
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);

View File

@@ -211,6 +211,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 +315,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

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

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