docs: plan cargo space height allocation
This commit is contained in:
970
docs/superpowers/plans/2026-07-10-inbound-cargo-space-height.md
Normal file
970
docs/superpowers/plans/2026-07-10-inbound-cargo-space-height.md
Normal 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 >= #{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 >= #{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 >= #{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 >= #{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.
|
||||
Reference in New Issue
Block a user