docs: plan cargo space height allocation

This commit is contained in:
zgw
2026-07-10 10:43:11 +08:00
parent c1c6181d71
commit 302cc0135f

View File

@@ -0,0 +1,970 @@
# Inbound Cargo Space Height 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:** Persist the WCS `height` level and enforce height-compatible, normal-space-first inbound allocation for warehouses 01 and 02, including `PUT_OCCUPIED` reallocation.
**Architecture:** Add `height` to the WCS request contract and persist it in the existing `mydo_job.height` column. Pass the value into the normal SQL Server lock query, apply `the_height >= requested height`, and sort exact-height candidates before oversized candidates. Reuse the persisted job height in the existing transactional `PUT_OCCUPIED` path. Apply the 01/02 cargo-space master-data update through an idempotent, assertion-guarded SQL script.
**Tech Stack:** Java 17, Spring Boot, MyBatis XML, SQL Server 2016, JUnit 5, Mockito, AssertJ, Maven, PowerShell `System.Data.SqlClient`.
---
## File Map
- `ruoyi-admin/src/main/java/com/ruoyi/api/domain/WcsInboundLocationRequest.java`: WCS request `height` property.
- `ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java`: request validation, idempotency, lock invocation, and height persistence.
- `ruoyi-admin/src/main/java/com/ruoyi/pda/service/PdaService.java`: height-aware lock contract.
- `ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java`: mapper parameter propagation and stale-lock compatibility check.
- `ruoyi-admin/src/main/resources/mapper/pda/PdaMapper.xml`: normal allocation capacity filter and exact-height priority.
- `ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java`: reject invalid legacy heights before `PUT_OCCUPIED` changes the old location.
- `ruoyi-admin/src/main/resources/mapper/bas/BasCargoSpaceMapper.xml`: height-compatible `PUT_OCCUPIED` candidate filter and ordering.
- `ruoyi-admin/src/test/java/com/ruoyi/api/domain/WcsInboundLocationRequestTest.java`: DTO contract test.
- `ruoyi-admin/src/test/java/com/ruoyi/api/controller/ExternalApiControllerWcsCodeTest.java`: validation, persistence, and retry behavior.
- `ruoyi-admin/src/test/java/com/ruoyi/pda/service/impl/PdaServiceImplTest.java`: height propagation and stale-lock behavior.
- `ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/PdaMapperSqlTest.java`: normal allocation SQL assertions.
- `ruoyi-admin/src/test/java/com/ruoyi/in/job/service/impl/MydoJobServiceImplTest.java`: invalid-height and inherited-height reallocation tests.
- `ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapperSqlTest.java`: reallocation SQL assertions.
- `ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/CargoSpaceHeightMigrationSqlTest.java`: migration contract test.
- `sql/20260710_cargo_space_height.sql`: guarded, repeatable 01/02 master-data update.
The worktree already contains unrelated edits in `PdaServiceImpl.java`, `PdaMapper.xml`, `PdaServiceImplTest.java`, `MydoJobServiceImpl.java`, and other files. Preserve those edits and do not stage or commit an entire overlapping file. The implementation stays uncommitted for review unless the task-specific hunks can be isolated without staging user-owned changes.
### Task 1: Add the Height Contracts
**Files:**
- Create: `ruoyi-admin/src/test/java/com/ruoyi/api/domain/WcsInboundLocationRequestTest.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/domain/WcsInboundLocationRequest.java`
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/pda/service/impl/PdaServiceImplTest.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/service/PdaService.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java`
- [ ] **Step 1: Write the failing DTO contract test**
```java
package com.ruoyi.api.domain;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
class WcsInboundLocationRequestTest {
@Test
void carriesHeightLevelAsBigDecimal() throws Exception {
WcsInboundLocationRequest request = new WcsInboundLocationRequest();
Method setter = WcsInboundLocationRequest.class.getMethod("setHeight", BigDecimal.class);
Method getter = WcsInboundLocationRequest.class.getMethod("getHeight");
setter.invoke(request, BigDecimal.valueOf(2));
assertThat(getter.invoke(request)).isEqualTo(BigDecimal.valueOf(2));
}
}
```
- [ ] **Step 2: Run the DTO test and verify RED**
Run:
```powershell
mvn -pl ruoyi-admin -Dtest=WcsInboundLocationRequestTest test
```
Expected: FAIL with `NoSuchMethodException: ...setHeight(java.math.BigDecimal)`.
- [ ] **Step 3: Add the DTO field and accessors**
Add `import java.math.BigDecimal;`, then add this field after `warehouseId` and these accessors before the closing brace:
```java
/**
* 货物高度等级:1=普通,2=高货。
*/
private BigDecimal height;
public BigDecimal getHeight() {
return height;
}
public void setHeight(BigDecimal height) {
this.height = height;
}
```
- [ ] **Step 4: Run the DTO test and verify GREEN**
Run:
```powershell
mvn -pl ruoyi-admin -Dtest=WcsInboundLocationRequestTest test
```
Expected: PASS, 1 test and 0 failures.
- [ ] **Step 5: Write the failing service-contract test**
Add `import com.ruoyi.pda.service.PdaService;`, `import java.lang.reflect.Method;`, and `import java.util.Map;`, then add this test to `PdaServiceImplTest`:
```java
@Test
void inboundCargoSpaceLockContractAcceptsHeightLevel() throws Exception {
Method method = PdaService.class.getMethod(
"lockInboundCargoSpace",
String.class,
String.class,
String.class,
BigDecimal.class);
assertThat(method.getReturnType()).isEqualTo(Map.class);
}
```
- [ ] **Step 6: Run the service-contract test and verify RED**
Run:
```powershell
mvn -pl ruoyi-admin '-Dtest=PdaServiceImplTest#inboundCargoSpaceLockContractAcceptsHeightLevel' test
```
Expected: FAIL with `NoSuchMethodException` for the four-argument method.
- [ ] **Step 7: Change the service contract without changing allocation behavior yet**
Add `import java.math.BigDecimal;` to `PdaService.java` and change the declaration to:
```java
Map<String, Object> lockInboundCargoSpace(
String trayCode,
String warehouseId,
String roadwayId,
BigDecimal height);
```
Change the matching implementation signature to:
```java
@Override
public Map<String, Object> lockInboundCargoSpace(
String trayCode,
String warehouseId,
String roadwayId,
BigDecimal height) {
```
Change the controller invocation so the project continues to compile with the new contract; Task 2 adds validation and persistence around this call:
```java
cargoSpace = pdaService.lockInboundCargoSpace(
job.getTrayCode(),
job.getWarehouseId(),
job.getRoadwayId(),
request == null ? null : request.getHeight());
```
- [ ] **Step 8: Run both contract tests and verify GREEN**
Run:
```powershell
mvn -pl ruoyi-admin '-Dtest=WcsInboundLocationRequestTest,PdaServiceImplTest#inboundCargoSpaceLockContractAcceptsHeightLevel' test
```
Expected: PASS, 2 tests and 0 failures.
### Task 2: Validate and Persist WCS Height
**Files:**
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/api/controller/ExternalApiControllerWcsCodeTest.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java`
- [ ] **Step 1: Add controller tests for invalid height, first allocation, and retries**
Add imports for `WcsInboundLocationRequest`, `ArgumentCaptor`, `BigDecimal`, `verifyNoInteractions`, and the following tests:
```java
@Test
void inboundLocationRejectsMissingHeightBeforeLookingUpJob() {
WcsInboundLocationRequest request = new WcsInboundLocationRequest();
request.setJobId("RK001");
AjaxResult result = controller.inboundLocation(request);
assertWcsError(result, "height必须为1或2");
verifyNoInteractions(mydoJobService, pdaService);
}
@Test
void inboundLocationRejectsUnsupportedHeightBeforeLookingUpJob() {
WcsInboundLocationRequest request = new WcsInboundLocationRequest();
request.setJobId("RK001");
request.setHeight(BigDecimal.valueOf(3));
AjaxResult result = controller.inboundLocation(request);
assertWcsError(result, "height必须为1或2");
verifyNoInteractions(mydoJobService, pdaService);
}
@Test
void inboundLocationPersistsHeightAfterLockingFirstCargoSpace() {
WcsInboundLocationRequest request = new WcsInboundLocationRequest();
request.setJobId("RK001");
request.setHeight(BigDecimal.ONE);
MydoJob job = new MydoJob();
job.setJobId("RK001");
job.setTrayCode("A001");
job.setWarehouseId("01");
job.setRoadwayId("R01");
when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job);
when(pdaService.lockInboundCargoSpace("A001", "01", "R01", BigDecimal.ONE))
.thenReturn(Map.of(
"roadway_id", "R01",
"cargo_space_id", "CS001",
"cargo_space_name", "01-01-01"));
when(mydoJobService.updateMydoJob(job)).thenReturn(1);
AjaxResult result = controller.inboundLocation(request);
assertWcsSuccess(result);
ArgumentCaptor<MydoJob> captor = ArgumentCaptor.forClass(MydoJob.class);
verify(mydoJobService).updateMydoJob(captor.capture());
assertThat(captor.getValue().getHeight()).isEqualByComparingTo("1");
assertThat(captor.getValue().getCargoSpaceId()).isEqualTo("CS001");
}
@Test
void inboundLocationReusesAssignedCargoSpaceForSameHeight() {
WcsInboundLocationRequest request = new WcsInboundLocationRequest();
request.setJobId("RK001");
request.setHeight(BigDecimal.ONE);
MydoJob job = new MydoJob();
job.setJobId("RK001");
job.setCargoSpaceId("CS001");
job.setHeight(new BigDecimal("1.00"));
when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job);
AjaxResult result = controller.inboundLocation(request);
assertWcsSuccess(result);
verifyNoInteractions(pdaService);
verify(mydoJobService, never()).updateMydoJob(job);
}
@Test
void inboundLocationRejectsDifferentHeightForAssignedCargoSpace() {
WcsInboundLocationRequest request = new WcsInboundLocationRequest();
request.setJobId("RK001");
request.setHeight(BigDecimal.valueOf(2));
MydoJob job = new MydoJob();
job.setJobId("RK001");
job.setCargoSpaceId("CS001");
job.setHeight(BigDecimal.ONE);
when(mydoJobService.selectMydoJobByJobId("RK001")).thenReturn(job);
AjaxResult result = controller.inboundLocation(request);
assertWcsError(result, "请求height与已分配作业不一致");
verifyNoInteractions(pdaService);
verify(mydoJobService, never()).updateMydoJob(job);
}
```
- [ ] **Step 2: Run the controller tests and verify RED**
Run:
```powershell
mvn -pl ruoyi-admin -Dtest=ExternalApiControllerWcsCodeTest test
```
Expected: the new tests FAIL because height is not validated, passed to the service, persisted, or compared on retries.
- [ ] **Step 3: Implement validation, persistence, and retry checks**
Add `import java.math.BigDecimal;`. At the start of `inboundLocation`, validate the request:
```java
BigDecimal requestedHeight = request == null ? null : request.getHeight();
if (!isSupportedHeight(requestedHeight)) {
return wcsError("height必须为1或2");
}
```
After resolving `job` and before locking, replace the existing cargo-space branch with:
```java
if (job.getCargoSpaceId() != null && !job.getCargoSpaceId().trim().isEmpty()) {
if (!isSupportedHeight(job.getHeight())) {
return wcsError("已分配入库作业height无效");
}
if (job.getHeight().compareTo(requestedHeight) != 0) {
return wcsError("请求height与已分配作业不一致");
}
return wcsSuccess(job);
}
Map<String, Object> cargoSpace;
try {
cargoSpace = pdaService.lockInboundCargoSpace(
job.getTrayCode(),
job.getWarehouseId(),
job.getRoadwayId(),
requestedHeight);
} catch (RuntimeException e) {
return wcsError(e.getMessage());
}
job.setRoadwayId(String.valueOf(cargoSpace.get("roadway_id")));
job.setCargoSpaceId(String.valueOf(cargoSpace.get("cargo_space_id")));
job.setCargoSpaceName(String.valueOf(cargoSpace.get("cargo_space_name")));
job.setHeight(requestedHeight);
int updateRows = mydoJobService.updateMydoJob(job);
if (updateRows <= 0) {
pdaService.releaseInboundCargoSpace(job.getTrayCode(), job.getWarehouseId());
return wcsError("入库货位回写作业失败");
}
```
Add this helper near the existing private response helpers:
```java
private boolean isSupportedHeight(BigDecimal height) {
return height != null
&& (height.compareTo(BigDecimal.ONE) == 0
|| height.compareTo(BigDecimal.valueOf(2)) == 0);
}
```
- [ ] **Step 4: Run the controller tests and verify GREEN**
Run:
```powershell
mvn -pl ruoyi-admin -Dtest=ExternalApiControllerWcsCodeTest test
```
Expected: PASS, all controller tests and 0 failures.
### Task 3: Enforce Height in Normal Cargo-Space Locking
**Files:**
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/pda/service/impl/PdaServiceImplTest.java`
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/pda/mapper/PdaMapperSqlTest.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java`
- Modify: `ruoyi-admin/src/main/resources/mapper/pda/PdaMapper.xml`
- [ ] **Step 1: Add failing service tests**
Add `import static org.mockito.Mockito.verifyNoInteractions;` if it is not already present, then add these tests to `PdaServiceImplTest`:
```java
@Test
void lockInboundCargoSpacePassesHeightAndReusesCompatibleLock() {
HashMap<String, Object> rule = new HashMap<>();
rule.put("allowed_cargo_type", "A");
when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule);
HashMap<String, Object> locked = new HashMap<>();
locked.put("cargo_space_id", "CS-HIGH");
locked.put("the_height", 2.0D);
when(pdaMapper.selectLockedCargoSpaceByTrayCode(any(HashMap.class))).thenReturn(locked);
Map<String, Object> result = service.lockInboundCargoSpace(
"A001", "01", "R01", BigDecimal.ONE);
assertThat(result).isSameAs(locked);
ArgumentCaptor<HashMap<String, Object>> captor = ArgumentCaptor.forClass(HashMap.class);
verify(pdaMapper).selectLockedCargoSpaceByTrayCode(captor.capture());
assertThat(captor.getValue()).containsEntry("v_height", BigDecimal.ONE);
verify(pdaMapper, never()).lockInboundCargoSpace(any(HashMap.class));
}
@Test
void lockInboundCargoSpaceRejectsIncompatibleExistingLock() {
HashMap<String, Object> rule = new HashMap<>();
rule.put("allowed_cargo_type", "A");
when(pdaMapper.selectMatchedTrayRule(any(HashMap.class))).thenReturn(rule);
HashMap<String, Object> locked = new HashMap<>();
locked.put("cargo_space_id", "CS-NORMAL");
locked.put("the_height", 1.0D);
when(pdaMapper.selectLockedCargoSpaceByTrayCode(any(HashMap.class))).thenReturn(locked);
assertThatThrownBy(() -> service.lockInboundCargoSpace(
"A001", "01", "R01", BigDecimal.valueOf(2)))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("已锁定货位高度不满足");
verify(pdaMapper, never()).lockInboundCargoSpace(any(HashMap.class));
}
@Test
void lockInboundCargoSpaceRejectsUnsupportedHeight() {
assertThatThrownBy(() -> service.lockInboundCargoSpace(
"A001", "01", "R01", BigDecimal.ZERO))
.isInstanceOf(RuntimeException.class)
.hasMessage("height必须为1或2");
verifyNoInteractions(pdaMapper);
}
```
- [ ] **Step 2: Add the failing normal-allocation SQL test**
Replace the existing `inboundCargoSpaceLockChoosesPlatoonWithMostEmptySpacesFirst` assertion body with:
```java
assertThat(lockSql)
.contains("and bcs.the_height &gt;= #{v_height}")
.contains("case when bcs.the_height = #{v_height} then 0 else 1 end as height_priority")
.contains("count(1) over (partition by bcs.the_platoon) as platoon_empty_count")
.contains("order by height_priority asc, platoon_empty_count desc");
String lockedSql = section(
xml,
"<select id=\"selectLockedCargoSpaceByTrayCode\"",
"</select>");
assertThat(lockedSql).contains("the_height");
```
- [ ] **Step 3: Run service and mapper tests and verify RED**
Run:
```powershell
mvn -pl ruoyi-admin '-Dtest=PdaServiceImplTest#lockInboundCargoSpace*,PdaMapperSqlTest#inboundCargoSpaceLockChoosesPlatoonWithMostEmptySpacesFirst' test
```
Expected: FAIL because `v_height`, compatibility validation, capacity filtering, priority ordering, and locked-row height are absent.
- [ ] **Step 4: Implement service validation and mapper propagation**
At the start of `lockInboundCargoSpace`, before the roadway validation, add:
```java
if (!isSupportedCargoHeight(height)) {
throw new RuntimeException("height必须为1或2");
}
```
Add the mapper parameter:
```java
lockMap.put("v_height", height);
```
Replace the existing locked-row return block with:
```java
if (locked != null && !locked.isEmpty()) {
if (toBigDecimal(locked.get("the_height")).compareTo(height) < 0) {
throw new RuntimeException("已锁定货位高度不满足本次请求");
}
return locked;
}
```
Add this helper beside the existing `toBigDecimal` helper:
```java
private boolean isSupportedCargoHeight(BigDecimal height) {
return height != null
&& (height.compareTo(BigDecimal.ONE) == 0
|| height.compareTo(BigDecimal.valueOf(2)) == 0);
}
```
- [ ] **Step 5: Implement SQL capacity filtering and exact-height priority**
In the lock subquery, add `the_height` and `height_priority`:
```xml
select bcs.cargo_space_id,
bcs.the_platoon,
bcs.the_column,
bcs.the_floor,
bcs.the_height,
case when bcs.the_height = #{v_height} then 0 else 1 end as height_priority,
count(1) over (partition by bcs.the_platoon) as platoon_empty_count
```
Add this candidate condition after the roadway condition:
```xml
and bcs.the_height &gt;= #{v_height}
```
Change the candidate ordering to:
```xml
order by height_priority asc, platoon_empty_count desc,
the_platoon asc, the_floor asc, the_column asc, cargo_space_id asc
```
Return the locked row's capacity:
```xml
select top 1 cargo_space_id, cargo_space_name, roadway_id, the_height
```
- [ ] **Step 6: Run service and mapper tests and verify GREEN**
Run:
```powershell
mvn -pl ruoyi-admin '-Dtest=PdaServiceImplTest#lockInboundCargoSpace*,PdaMapperSqlTest#inboundCargoSpaceLockChoosesPlatoonWithMostEmptySpacesFirst' test
```
Expected: PASS, all selected tests and 0 failures.
### Task 4: Preserve Height During `PUT_OCCUPIED` Reallocation
**Files:**
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/in/job/service/impl/MydoJobServiceImplTest.java`
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/BasCargoSpaceMapperSqlTest.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java`
- Modify: `ruoyi-admin/src/main/resources/mapper/bas/BasCargoSpaceMapper.xml`
- [ ] **Step 1: Add the failing invalid-height reallocation test**
Add `import java.math.BigDecimal;` and this test:
```java
@Test
void handleWcsPutOccupiedExceptionRejectsUnsupportedJobHeightBeforeChangingLocation() {
MydoJob job = new MydoJob();
job.setJobId("RK002");
job.setInOrOut(10L);
job.setTrayCode("TRAY002");
job.setWarehouseId("01");
job.setRoadwayId("R01");
job.setCargoSpaceId("OLD-CS");
job.setHeight(BigDecimal.ZERO);
when(mydoJobMapper.selectMydoJobByJobId("RK002")).thenReturn(job);
AjaxResult result = service.handleWcsPutOccupiedException("RK002");
assertThat(result.isSuccess()).isFalse();
assertThat(result.get(AjaxResult.MSG_TAG)).isEqualTo("入库作业height必须为1或2");
verifyNoInteractions(basCargoSpaceMapper);
}
```
Set `job.setHeight(BigDecimal.ONE);` in the existing successful `handleWcsPutOccupiedExceptionMarksOldLocationAndReturnsReassignedInboundJob` fixture and add this verification after the call:
```java
verify(basCargoSpaceMapper).selectAvailableInboundCargoSpaceInRoadway(
argThat(candidate -> candidate.getHeight().compareTo(BigDecimal.ONE) == 0));
```
- [ ] **Step 2: Add the failing reallocation SQL test**
Extend `putOccupiedReallocationChoosesPlatoonWithMostAvailableCargoSpaces` with:
```java
assertThat(selectSql)
.contains("and bcs.the_height &gt;= #{height}")
.contains("case when bcs.the_height = #{height} then 0 else 1 end as height_priority")
.contains("count(1) over (partition by bcs.the_platoon) as platoon_empty_count")
.contains("order by height_priority asc, platoon_empty_count desc");
```
- [ ] **Step 3: Run the reallocation tests and verify RED**
Run:
```powershell
mvn -pl ruoyi-admin '-Dtest=MydoJobServiceImplTest#handleWcsPutOccupiedException*,BasCargoSpaceMapperSqlTest#putOccupiedReallocationChoosesPlatoonWithMostAvailableCargoSpaces' test
```
Expected: FAIL because invalid job height is not rejected and the reallocation SQL has no capacity or priority rule.
- [ ] **Step 4: Validate persisted height before changing the old location**
Add `import java.math.BigDecimal;`. After warehouse/roadway validation and before `markInboundNeedCheck`, add:
```java
if (!isSupportedCargoHeight(job.getHeight())) {
return AjaxResult.error("入库作业height必须为1或2");
}
```
Add this private helper near `rollbackError`:
```java
private boolean isSupportedCargoHeight(BigDecimal height) {
return height != null
&& (height.compareTo(BigDecimal.ONE) == 0
|| height.compareTo(BigDecimal.valueOf(2)) == 0);
}
```
- [ ] **Step 5: Add the same capacity and ordering rules to reallocation SQL**
Add this expression to the candidate projection before `platoon_empty_count`:
```xml
case when bcs.the_height = #{height} then 0 else 1 end as height_priority,
```
Add this predicate after the roadway predicate:
```xml
and bcs.the_height &gt;= #{height}
```
Change the final ordering to:
```xml
order by height_priority asc, platoon_empty_count desc,
the_platoon asc, the_floor asc, the_column asc, cargo_space_id asc
```
- [ ] **Step 6: Run the reallocation tests and verify GREEN**
Run:
```powershell
mvn -pl ruoyi-admin '-Dtest=MydoJobServiceImplTest#handleWcsPutOccupiedException*,BasCargoSpaceMapperSqlTest#putOccupiedReallocationChoosesPlatoonWithMostAvailableCargoSpaces' test
```
Expected: PASS, all selected tests and 0 failures.
### Task 5: Create and Execute the Guarded Master-Data Migration
**Files:**
- Create: `ruoyi-admin/src/test/java/com/ruoyi/bas/cargospace/mapper/CargoSpaceHeightMigrationSqlTest.java`
- Create: `sql/20260710_cargo_space_height.sql`
- [ ] **Step 1: Write the failing migration contract test**
```java
package com.ruoyi.bas.cargospace.mapper;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
class CargoSpaceHeightMigrationSqlTest {
@Test
void assignsHeightLevelsOnlyToWarehouses01And02WithSafetyChecks() throws Exception {
String sql = Files.readString(resolveSql()).toLowerCase();
assertThat(sql)
.contains("set xact_abort on")
.contains("begin transaction")
.contains("when the_floor in (1, 5) then 2 else 1 end")
.contains("where warehouse_id in ('01', '02')")
.contains("@target_count <> 340")
.contains("@high_count <> 136")
.contains("@normal_count <> 204")
.contains("rollback transaction")
.contains("commit transaction");
}
private Path resolveSql() {
return Stream.of(
Path.of("sql/20260710_cargo_space_height.sql"),
Path.of("../sql/20260710_cargo_space_height.sql"))
.filter(Files::exists)
.findFirst()
.orElseThrow();
}
}
```
- [ ] **Step 2: Run the migration contract test and verify RED**
Run:
```powershell
mvn -pl ruoyi-admin -Dtest=CargoSpaceHeightMigrationSqlTest test
```
Expected: FAIL with `NoSuchElementException` because the SQL file does not exist.
- [ ] **Step 3: Create the guarded, repeatable SQL script**
```sql
set nocount on;
set xact_abort on;
begin try
begin transaction;
declare @target_count int;
declare @high_count int;
declare @normal_count int;
declare @updated_rows int;
select @target_count = count(*)
from dbo.bas_cargo_space
where warehouse_id in ('01', '02');
select @high_count = count(*)
from dbo.bas_cargo_space
where warehouse_id in ('01', '02')
and the_floor in (1, 5);
select @normal_count = count(*)
from dbo.bas_cargo_space
where warehouse_id in ('01', '02')
and the_floor not in (1, 5);
if @target_count <> 340
begin
;throw 51001, 'Unexpected 01/02 cargo-space count; no changes committed.', 1;
end;
if @high_count <> 136
begin
;throw 51002, 'Unexpected high cargo-space count; no changes committed.', 1;
end;
if @normal_count <> 204
begin
;throw 51003, 'Unexpected normal cargo-space count; no changes committed.', 1;
end;
update dbo.bas_cargo_space
set the_height = case when the_floor in (1, 5) then 2 else 1 end
where warehouse_id in ('01', '02');
set @updated_rows = @@rowcount;
if @updated_rows <> @target_count
begin
;throw 51004, 'Not all 01/02 cargo spaces were updated; no changes committed.', 1;
end;
if exists (
select 1
from dbo.bas_cargo_space
where warehouse_id in ('01', '02')
and (
the_height is null
or the_height <> case when the_floor in (1, 5) then 2 else 1 end
)
)
begin
;throw 51005, 'Cargo-space height verification failed; no changes committed.', 1;
end;
commit transaction;
select warehouse_id,
the_floor,
the_height,
count(*) as cargo_space_count
from dbo.bas_cargo_space
where warehouse_id in ('01', '02')
group by warehouse_id, the_floor, the_height
order by warehouse_id, the_floor, the_height;
end try
begin catch
if @@trancount > 0
rollback transaction;
throw;
end catch;
```
- [ ] **Step 4: Run the migration contract test and verify GREEN**
Run:
```powershell
mvn -pl ruoyi-admin -Dtest=CargoSpaceHeightMigrationSqlTest test
```
Expected: PASS, 1 test and 0 failures.
- [ ] **Step 5: Re-run the read-only preflight immediately before writing**
Run this read-only PowerShell block from the repository root. It extracts the configured master connection without printing credentials:
```powershell
$config = Get-Content -Raw 'ruoyi-admin/src/main/resources/application-druid.yml'
$master = [regex]::Match($config, '(?ms)^[ \t]*master:[ \t]*\r?\n(?<body>.*?)(?=^[ \t]*slave:)').Groups['body'].Value
$url = [regex]::Match($master, '(?m)^[ \t]*url:[ \t]*(.+)$').Groups[1].Value.Trim()
$username = [regex]::Match($master, '(?m)^[ \t]*username:[ \t]*(.+)$').Groups[1].Value.Trim()
$password = [regex]::Match($master, '(?m)^[ \t]*password:[ \t]*(.+)$').Groups[1].Value.Trim()
$jdbc = [regex]::Match($url, 'jdbc:sqlserver://(?<host>[^:;]+):(?<port>\d+);databaseName=(?<db>[^;]+)')
$builder = [System.Data.SqlClient.SqlConnectionStringBuilder]::new()
$builder.DataSource = '{0},{1}' -f $jdbc.Groups['host'].Value, $jdbc.Groups['port'].Value
$builder.InitialCatalog = $jdbc.Groups['db'].Value
$builder.UserID = $username
$builder.Password = $password
$builder.Encrypt = $false
$builder.TrustServerCertificate = $true
$builder.ApplicationIntent = [System.Data.SqlClient.ApplicationIntent]::ReadOnly
$connection = [System.Data.SqlClient.SqlConnection]::new($builder.ConnectionString)
try {
$connection.Open()
$command = $connection.CreateCommand()
$command.CommandText = @'
select warehouse_id,
the_floor,
the_height,
count(*) as cargo_space_count
from dbo.bas_cargo_space
where warehouse_id in ('01', '02')
group by warehouse_id, the_floor, the_height
order by warehouse_id, the_floor, the_height;
'@
$table = [System.Data.DataTable]::new()
$adapter = [System.Data.SqlClient.SqlDataAdapter]::new($command)
[void] $adapter.Fill($table)
$table | Format-Table -AutoSize
} finally {
$connection.Dispose()
}
```
Expected before first execution: 340 rows total, all currently `NULL`; 136 rows on floors 1/5 and 204 rows on floors 2/3/4.
- [ ] **Step 6: Execute the migration against `FTC25009-WMS` without printing credentials**
Run this PowerShell block from the repository root:
```powershell
$config = Get-Content -Raw 'ruoyi-admin/src/main/resources/application-druid.yml'
$master = [regex]::Match($config, '(?ms)^[ \t]*master:[ \t]*\r?\n(?<body>.*?)(?=^[ \t]*slave:)').Groups['body'].Value
$url = [regex]::Match($master, '(?m)^[ \t]*url:[ \t]*(.+)$').Groups[1].Value.Trim()
$username = [regex]::Match($master, '(?m)^[ \t]*username:[ \t]*(.+)$').Groups[1].Value.Trim()
$password = [regex]::Match($master, '(?m)^[ \t]*password:[ \t]*(.+)$').Groups[1].Value.Trim()
$jdbc = [regex]::Match($url, 'jdbc:sqlserver://(?<host>[^:;]+):(?<port>\d+);databaseName=(?<db>[^;]+)')
$builder = [System.Data.SqlClient.SqlConnectionStringBuilder]::new()
$builder.DataSource = '{0},{1}' -f $jdbc.Groups['host'].Value, $jdbc.Groups['port'].Value
$builder.InitialCatalog = $jdbc.Groups['db'].Value
$builder.UserID = $username
$builder.Password = $password
$builder.Encrypt = $false
$builder.TrustServerCertificate = $true
$connection = [System.Data.SqlClient.SqlConnection]::new($builder.ConnectionString)
try {
$connection.Open()
$command = $connection.CreateCommand()
$command.CommandTimeout = 60
$command.CommandText = Get-Content -Raw 'sql/20260710_cargo_space_height.sql'
[void] $command.ExecuteNonQuery()
} finally {
$connection.Dispose()
}
```
Expected: no exception. Any count or invariant mismatch raises SQL error 51001-51005 and rolls back.
- [ ] **Step 7: Query and record the live post-migration distribution**
Run this read-only PowerShell block:
```powershell
$config = Get-Content -Raw 'ruoyi-admin/src/main/resources/application-druid.yml'
$master = [regex]::Match($config, '(?ms)^[ \t]*master:[ \t]*\r?\n(?<body>.*?)(?=^[ \t]*slave:)').Groups['body'].Value
$url = [regex]::Match($master, '(?m)^[ \t]*url:[ \t]*(.+)$').Groups[1].Value.Trim()
$username = [regex]::Match($master, '(?m)^[ \t]*username:[ \t]*(.+)$').Groups[1].Value.Trim()
$password = [regex]::Match($master, '(?m)^[ \t]*password:[ \t]*(.+)$').Groups[1].Value.Trim()
$jdbc = [regex]::Match($url, 'jdbc:sqlserver://(?<host>[^:;]+):(?<port>\d+);databaseName=(?<db>[^;]+)')
$builder = [System.Data.SqlClient.SqlConnectionStringBuilder]::new()
$builder.DataSource = '{0},{1}' -f $jdbc.Groups['host'].Value, $jdbc.Groups['port'].Value
$builder.InitialCatalog = $jdbc.Groups['db'].Value
$builder.UserID = $username
$builder.Password = $password
$builder.Encrypt = $false
$builder.TrustServerCertificate = $true
$builder.ApplicationIntent = [System.Data.SqlClient.ApplicationIntent]::ReadOnly
$connection = [System.Data.SqlClient.SqlConnection]::new($builder.ConnectionString)
try {
$connection.Open()
$command = $connection.CreateCommand()
$command.CommandText = @'
select warehouse_id,
the_floor,
the_height,
count(*) as cargo_space_count
from dbo.bas_cargo_space
where warehouse_id in ('01', '02')
group by warehouse_id, the_floor, the_height
order by warehouse_id, the_floor, the_height;
'@
$table = [System.Data.DataTable]::new()
$adapter = [System.Data.SqlClient.SqlDataAdapter]::new($command)
[void] $adapter.Fill($table)
$table | Format-Table -AutoSize
} finally {
$connection.Dispose()
}
```
Expected:
- Warehouse 01: floors 1 and 5 each have 48 rows at height 2; floors 2, 3, and 4 each have 48 rows at height 1.
- Warehouse 02: floors 1 and 5 each have 20 rows at height 2; floors 2, 3, and 4 each have 20 rows at height 1.
- Totals: 136 height-2 rows, 204 height-1 rows, 0 null/other rows.
### Task 6: Full Verification and Completion Audit
**Files:**
- Verify all files listed above.
- Do not stage unrelated worktree changes.
- [ ] **Step 1: Run focused tests for every new rule**
Run:
```powershell
mvn -pl ruoyi-admin -Dtest=WcsInboundLocationRequestTest,ExternalApiControllerWcsCodeTest,PdaServiceImplTest,PdaMapperSqlTest,MydoJobServiceImplTest,BasCargoSpaceMapperSqlTest,CargoSpaceHeightMigrationSqlTest test
```
Expected: PASS, 0 failures and 0 errors.
- [ ] **Step 2: Run the complete backend test suite**
Run:
```powershell
mvn -pl ruoyi-admin test
```
Expected: `BUILD SUCCESS`, 0 failures and 0 errors.
- [ ] **Step 3: Compile/package all required backend modules**
Run:
```powershell
mvn -pl ruoyi-admin -am package -DskipTests
```
Expected: `BUILD SUCCESS` for the reactor.
- [ ] **Step 4: Validate XML and diff hygiene**
Run:
```powershell
git diff --check
git status --short
git diff -- ruoyi-admin/src/main/java/com/ruoyi/api/domain/WcsInboundLocationRequest.java ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java ruoyi-admin/src/main/java/com/ruoyi/pda/service/PdaService.java ruoyi-admin/src/main/java/com/ruoyi/pda/service/impl/PdaServiceImpl.java ruoyi-admin/src/main/resources/mapper/pda/PdaMapper.xml ruoyi-admin/src/main/java/com/ruoyi/in/job/service/impl/MydoJobServiceImpl.java ruoyi-admin/src/main/resources/mapper/bas/BasCargoSpaceMapper.xml sql/20260710_cargo_space_height.sql
```
Expected: no whitespace errors. Review the diff to confirm existing inventory/PDA changes remain intact and only task-specific hunks were added.
- [ ] **Step 5: Audit each requirement against authoritative evidence**
Confirm all of the following:
- Live DB query proves 01/02 floors 1/5 are `the_height=2` and all other 01/02 floors are `the_height=1`.
- Controller tests prove missing/invalid height rejection, persistence, same-height idempotency, and different-height conflict.
- Normal mapper SQL proves height 2 cannot select height 1 and height 1 ranks height 1 before height 2.
- `PUT_OCCUPIED` tests and SQL prove reallocation uses the persisted job height with the same rules.
- Full test and package commands both have fresh successful output.
Do not claim completion or mark the goal complete until all five evidence items are present.